From 5b42239f0cc6b8b7fa58a004beba9da26af14b77 Mon Sep 17 00:00:00 2001 From: David Sloan Date: Thu, 22 Jul 2021 09:14:32 +0100 Subject: [PATCH 1/3] Replacing Gradle with SBT. Building for target 2.12 and 2.13 scala. Update github actions workflow and documentation. --- .github/workflows/build.yml | 20 +- .github/workflows/publish-manually.yml | 40 ---- .github/workflows/publish.yml | 33 +-- .gitignore | 4 + .sbtopts | 4 + README.md | 46 +--- build.gradle | 223 ------------------ build.sbt | 11 + gradle.properties | 9 - gradle/wrapper/gradle-wrapper.jar | Bin 58694 -> 0 bytes gradle/wrapper/gradle-wrapper.properties | 5 - gradlew | 183 -------------- gradlew.bat | 103 -------- project/.sbtopts | 4 + project/Dependencies.scala | 75 ++++++ project/Settings.scala | 90 +++++++ project/Versioning.scala | 14 ++ project/build.properties | 1 + project/plugins.sbt | 1 + .../secrets/async/AsyncFunctionLoop.scala | 7 +- .../secrets/config/AWSProviderConfig.scala | 4 +- .../secrets/config/AWSProviderSettings.scala | 2 +- ...r.scala => AbstractConfigExtensions.scala} | 2 +- ...onfig.scala => Aes256ProviderConfig.scala} | 3 +- .../secrets/config/AzureProviderConfig.scala | 4 +- .../config/AzureProviderSettings.scala | 2 +- .../secrets/config/ENVProviderConfig.scala | 6 +- .../secrets/config/VaultProviderConfig.scala | 4 +- .../config/VaultProviderSettings.scala | 17 +- .../connect/secrets/io/FileWriter.scala | 10 +- .../io/lenses/connect/secrets/package.scala | 16 +- .../connect/secrets/providers/AWSHelper.scala | 18 +- .../secrets/providers/AWSSecretProvider.scala | 9 +- .../providers/Aes256DecodingHelper.scala | 8 +- .../providers/Aes256DecodingProvider.scala | 14 +- .../secrets/providers/AzureHelper.scala | 20 +- .../providers/AzureSecretProvider.scala | 16 +- .../secrets/providers/ENVSecretProvider.scala | 17 +- .../secrets/providers/VaultHelper.scala | 4 +- .../providers/VaultSecretProvider.scala | 32 +-- .../connect/secrets/utils/WithRetry.scala | 20 +- .../connect/secrets/vault/VaultTestUtils.java | 3 - .../secrets/async/AsyncFunctionLoopTest.scala | 4 +- .../secrets/io/FileWriterOnceTest.scala | 27 +-- .../providers/AWSSecretProviderTest.scala | 24 +- .../providers/Aes256DecodingHelperTest.scala | 10 +- .../Aes256DecodingProviderTest.scala | 33 +-- .../providers/AesDecodingTestHelper.scala | 8 +- .../providers/AzureSecretProviderTest.scala | 21 +- .../secrets/providers/DecodeTest.scala | 10 +- .../providers/ENVSecretProviderTest.scala | 18 +- .../providers/VaultSecretProviderTest.scala | 31 +-- 52 files changed, 395 insertions(+), 895 deletions(-) delete mode 100644 .github/workflows/publish-manually.yml create mode 100644 .sbtopts delete mode 100644 build.gradle create mode 100644 build.sbt delete mode 100644 gradle.properties delete mode 100644 gradle/wrapper/gradle-wrapper.jar delete mode 100644 gradle/wrapper/gradle-wrapper.properties delete mode 100755 gradlew delete mode 100644 gradlew.bat create mode 100644 project/.sbtopts create mode 100644 project/Dependencies.scala create mode 100644 project/Settings.scala create mode 100644 project/Versioning.scala create mode 100644 project/build.properties create mode 100644 project/plugins.sbt rename src/main/scala/io/lenses/connect/secrets/config/{AbstractConfigProvider.scala => AbstractConfigExtensions.scala} (96%) rename src/main/scala/io/lenses/connect/secrets/config/{Aes256DecodingProviderConfig.scala => Aes256ProviderConfig.scala} (99%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0906dea..5a5af08 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,20 +4,14 @@ on: [push] jobs: build: - runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up JDK 1.8 - uses: actions/setup-java@v1 + - name: Checkout + uses: actions/checkout@v2 + - name: Setup Scala + uses: olafurpg/setup-scala@v11 with: - java-version: 1.8 - - name: Download gradle - run: ./gradlew --version - - name: Run tests - run: ./gradlew test - - name: Run compile - run: ./gradlew compile - - name: Run build - run: ./gradlew build \ No newline at end of file + java-version: openjdk@1.14 + - name: Test + run: sbt +test diff --git a/.github/workflows/publish-manually.yml b/.github/workflows/publish-manually.yml deleted file mode 100644 index 671d213..0000000 --- a/.github/workflows/publish-manually.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Publish-manually - -on: [workflow_dispatch] - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - name: Set up JDK 1.8 - uses: actions/setup-java@v1 - with: - java-version: 1.8 - - shell: bash - env: - GRADLE_PROPERTIES: ${{ secrets.GRADLE_PROPERTIES }} - SIGNING_GPG_KEY: ${{ secrets.SIGNING_GPG_KEY }} - run: | - base64 -d <<< "$SIGNING_GPG_KEY" > /tmp/secring.gpg - echo "$GRADLE_PROPERTIES" > gradle.properties - - name: Get the tag - id: get_tag - run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} - - name: Build shadowJar for Github - run: ./gradlew -Prelease -Pversion=${{ steps.get_tag.outputs.VERSION }} shadowJar - - name: Release to Github - uses: softprops/action-gh-release@v1 - with: - files: build/libs/secret-provider-${{ steps.get_tag.outputs.VERSION }}-all.jar - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Upload archive - run: ./gradlew -Prelease -Pversion=${{ steps.get_tag.outputs.VERSION }} signArchives uploadArchives - - name: Wait for nexus to settle - run: sleep 30 - - name: Release archive - run: ./gradlew -Prelease -Pversion=${{ steps.get_tag.outputs.VERSION }} closeAndReleaseRepository - diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c29bbaa..6a6ceaf 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,32 +11,25 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up JDK 1.8 - uses: actions/setup-java@v1 + - name: Checkout + uses: actions/checkout@v2 + - name: Setup Scala + uses: olafurpg/setup-scala@v11 with: - java-version: 1.8 - - shell: bash - env: - GRADLE_PROPERTIES: ${{ secrets.GRADLE_PROPERTIES }} - SIGNING_GPG_KEY: ${{ secrets.SIGNING_GPG_KEY }} - run: | - base64 -d <<< "$SIGNING_GPG_KEY" > /tmp/secring.gpg - echo "$GRADLE_PROPERTIES" > gradle.properties + java-version: openjdk@1.14 - name: Get the tag id: get_tag run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} - - name: Build shadowJar for Github - run: ./gradlew -Prelease -Pversion=${{ steps.get_tag.outputs.VERSION }} shadowJar + - name: Assembly + run: sbt +assembly + env: + LENSES_TAG_NAME: ${{ steps.get_tag.outputs.VERSION }} - name: Release to Github uses: softprops/action-gh-release@v1 with: - files: build/libs/secret-provider-${{ steps.get_tag.outputs.VERSION }}-all.jar + files: | + target/scala-2.12/secret-provider_2.12-${{ steps.get_tag.outputs.VERSION }}-all.jar + target/scala-2.13/secret-provider_2.13-${{ steps.get_tag.outputs.VERSION }}-all.jar env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Build and upload jar to nexus - run: ./gradlew -Prelease -Pversion=${{ steps.get_tag.outputs.VERSION }} signArchives uploadArchives - - name: Wait for nexus to settle - run: sleep 30 - - name: Release to nexus - run: ./gradlew -Prelease -Pversion=${{ steps.get_tag.outputs.VERSION }} closeAndReleaseRepository + LENSES_TAG_NAME: ${{ steps.get_tag.outputs.VERSION }} diff --git a/.gitignore b/.gitignore index 59392e0..220a21b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,7 @@ /build/ /.classpath /.project +/target/ +/project/target/ +/.bsp/ +/project/project/target/ diff --git a/.sbtopts b/.sbtopts new file mode 100644 index 0000000..5a87016 --- /dev/null +++ b/.sbtopts @@ -0,0 +1,4 @@ +-J-Xmx4G +-J-Xms1024M +-J-Xss2M +-J-XX:MaxMetaspaceSize=2G \ No newline at end of file diff --git a/README.md b/README.md index 083f04f..16d29e1 100644 --- a/README.md +++ b/README.md @@ -6,32 +6,10 @@ Secret provider for Kafka to provide indirect look up of configuration values. - -## Installing - -Maven -```xml - - io.lenses - secret-provider - 2.1.3 - -``` - -SBT -```bash -libraryDependencies += "io.lenses" % "secret-provider" % "2.1.3" -``` - -Gradle -```bash -compile 'io.lenses:secret-provider:2.1.3' -``` - ## Description External secret providers allow for indirect references to be placed in an -applications configuration, so for example, that secrets are not exposed in the +applications' configuration, so for example, that secrets are not exposed in the Worker API endpoints of Kafka Connect. For [Documentation](https://docs.lenses.io/4.0/integrations/connectors/secret-providers/). @@ -46,28 +24,16 @@ and submit a pull request. Thanks! ### Building -***Requires gradle 6.0 to build.*** - -To build - -```bash -gradle compile -``` +***Requires SBT to build.*** -To test +To build the (scala 2.12 and 2.13) assemblies for use with Kafka Connect (also runs tests): ```bash -gradle test +sbt +assembly ``` -To create a fat jar +To run tests: ```bash -gradle shadowJar -``` - -You can also use the gradle wrapper - -``` -./gradlew shadowJar +sbt +test ``` diff --git a/build.gradle b/build.gradle deleted file mode 100644 index c5da531..0000000 --- a/build.gradle +++ /dev/null @@ -1,223 +0,0 @@ -/* - * - * * Copyright 2017-2020 Lenses.io Ltd - * - */ - -plugins { - id "com.github.maiflai.scalatest" version "0.26" - id "com.github.johnrengelman.shadow" version "5.2.0" - id 'io.codearte.nexus-staging' version '0.21.2' - id "net.researchgate.release" version "2.8.1" -} - -allprojects { - group = 'io.lenses' - version = project.version - description = "connect-secret-provider" - apply plugin: 'java' - apply plugin: 'scala' - apply plugin: 'com.github.johnrengelman.shadow' - apply plugin: 'maven' - apply plugin: 'maven-publish' - apply plugin: 'signing' - apply plugin: 'com.github.maiflai.scalatest' - apply plugin: 'io.codearte.nexus-staging' - - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - - - ext { - scalaMajorVersion = "2.12" - scala = "2.12.10" - scalaCheck = "1.14.3" - scalaLoggingVersion = "3.9.2" - kafkaVersion = "2.5.0" - vaultVersion = "5.1.0" - azureVersion = "1.22.0" - azureKeyVaultVersion = "4.1.1" - azureIdentityVersion = "1.0.5" - awsSecretsVersion = "1.11.762" - - //test - scalaTest = "3.1.1" - mockitoVersion = "1.13.0" - pegDownVersion = "1.1.0" - slf4jVersion = "1.7.26" - commonsIOVersion = "1.3.2" - jettyVersion = "9.4.19.v20190610" - testContainersVersion = "1.12.3" - flexmarkVersion = "0.35.10" - - gitCommitHash = ("git rev-parse HEAD").execute().text.trim() - gitTag = ("git describe --abbrev=0 --tags").execute().text.trim() - gitRepo = ("git remote get-url origin").execute().text.trim() - } - - repositories { - mavenLocal() - mavenCentral() - } - - jar { - manifest { - attributes( - "Version": project.version, - "Kafka-Version": "${kafkaVersion}", - "Created-By" : "Lenses", - "Created-At" : new Date().format("YYYYMMDDHHmm"), - "Git-Repo": "${gitRepo}", - "Git-Commit-Hash": "${gitCommitHash}", - "Git-Tag": "${gitTag}", - "Docs" : "https://docs.lenses.io/connectors/" - ) - } - } - - shadowJar { - archiveFileName = "${project.name}-${project.version}-all.jar" - zip64 true - mergeServiceFiles() - - manifest { - attributes( - "Version": project.version, - "Kafka-Version": "${kafkaVersion}", - "Created-By" : "Lenses", - "Created-At" : new Date().format("YYYYMMDDHHmm"), - "Git-Repo": "${gitRepo}", - "Git-Commit-Hash": "${gitCommitHash}", - "Git-Tag": "${gitTag}", - "Docs" : "https://docs.lenses.io/connectors/" - ) - } - - dependencies { - exclude(dependency("org.apache.avro:.*")) - exclude(dependency("org.apache.kafka:.*")) - exclude(dependency("io.confluent:.*")) - exclude(dependency("org.apache.kafka:.*")) - exclude(dependency("org.apache.zookeeper:.*")) - } - } - - dependencies { - compile "org.scala-lang:scala-library:${scala}" - compile "org.scala-lang:scala-compiler:${scala}" - compile "com.typesafe.scala-logging:scala-logging_${scalaMajorVersion}:${scalaLoggingVersion}" - compile "org.apache.kafka:connect-api:${kafkaVersion}" - compile "com.bettercloud:vault-java-driver:${vaultVersion}" - compile "com.azure:azure-security-keyvault-secrets:${azureKeyVaultVersion}" - compile "com.azure:azure-identity:${azureIdentityVersion}" - compile "com.amazonaws:aws-java-sdk-secretsmanager:${awsSecretsVersion}" - - testImplementation "org.mockito:mockito-scala_${scalaMajorVersion}:${mockitoVersion}" - testImplementation "org.scalacheck:scalacheck_${scalaMajorVersion}:${scalaCheck}" - testImplementation "org.scalatest:scalatest_${scalaMajorVersion}:${scalaTest}" - testImplementation "org.eclipse.jetty:jetty-server:${jettyVersion}" - testImplementation "org.apache.commons:commons-io:${commonsIOVersion}" - testImplementation "org.slf4j:slf4j-api:${slf4jVersion}" - testImplementation "org.slf4j:slf4j-simple:${slf4jVersion}" - testImplementation "org.pegdown:pegdown:${pegDownVersion}" - testImplementation "com.vladsch.flexmark:flexmark-all:${flexmarkVersion}" - } - - test { - minHeapSize '256m' - maxHeapSize '2048m' - } - - task testJar(type: Jar, dependsOn: testClasses) { - archiveFileName = "test-${project.archivesBaseName}" - from sourceSets.test.output - } - - task sourcesJar(type: Jar) { - classifier = 'sources' - from sourceSets.main.allSource - } - - task javadocJar(type: Jar) { - classifier = 'javadoc' - from javadoc - } - - task scaladocJar(type: Jar) { - classifier = 'scaladoc' - from '../LICENSE' - from scaladoc - } - - task compile(dependsOn: 'compileScala') - - task fatJar(dependsOn: [test, shadowJar]) - - artifacts { - archives javadocJar - archives scaladocJar - archives sourcesJar - } - - nexusStaging { - username ossrhUsername - password ossrhPassword - } - - signing { - required { gradle.taskGraph.hasTask("uploadArchives") } - sign configurations.archives - } - - nexusStaging { - username ossrhUsername - password ossrhPassword - } - -// OSSRH publication - if (project.hasProperty('release')) { - uploadArchives { - repositories { - mavenDeployer { - // POM signature - beforeDeployment { MavenDeployment deployment -> signing.signPom(deployment) } - // Target repository - repository(url: "https://oss.sonatype.org/service/local/staging/deploy/maven2/") { - authentication(userName: ossrhUsername, password: ossrhPassword) - } - pom.project { - name project.name - description project.description - packaging 'jar' - url 'https://github.com/lensesio/connect-secret-provider' - - scm { - connection 'scm:git:https://github.com/lensesio/connect-secret-provider.git' - developerConnection 'scm:git:git@github.com:lensesio/connect-secret-provider.git' - url 'https://github.com/lensesio/connect-secret-provider.git' - } - - licenses { - license { - name 'Apache License 2.0' - url 'https://www.apache.org/licenses/LICENSE-2.0.html' - distribution 'repo' - } - } - - developers { - developer { - id = 'andrewstevenson' - name = 'Andrew Stevenson' - email = 'andrew@lenses.io' - } - } - } - } - } - } - } -} - -project.tasks.compileScala.scalaCompileOptions.additionalParameters = ["-target:jvm-1.8"] -project.tasks.compileTestScala.scalaCompileOptions.additionalParameters = ["-target:jvm-1.8"] diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..9b8f3cc --- /dev/null +++ b/build.sbt @@ -0,0 +1,11 @@ +import Settings.modulesSettings + +name := "secret-provider" + +javaOptions ++= Seq("-Xms512M", "-Xmx2048M", "-XX:+CMSClassUnloadingEnabled") +lazy val root = (project in file(".")) + .settings(modulesSettings) + + + + diff --git a/gradle.properties b/gradle.properties deleted file mode 100644 index ce85008..0000000 --- a/gradle.properties +++ /dev/null @@ -1,9 +0,0 @@ -# -# /* -# * Copyright 2017-2020 Lenses.io Ltd -# */ -# - -version=0.0.2 -ossrhUsername=you -ossrhPassword=me diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 490fda8577df6c95960ba7077c43220e5bb2c0d9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 58694 zcma&OV~}Oh(k5J8>Mq;1ZQHhO+v>7y+qO>Gc6Hgdjp>5?}0s%q%y~>Cv3(!c&iqe4q$^V<9O+7CU z|6d2bzlQvOI?4#hN{EUmDbvb`-pfo*NK4Vs&cR60P)<+IG%C_BGVL7RP11}?Ovy}9 zNl^cQJPR>SIVjSkXhS0@IVhqGLL)&%E<(L^ymkEXU!M5)A^-c;K>yy`Ihy@nZ}orr zK>gFl%+bKu+T{P~iuCWUZjJ`__9l-1*OFwCg_8CkKtLEEKtOc=d5NH%owJkk-}N#E z7Pd;x29C}qj>HVKM%D&SPSJ`JwhR2oJPU0u3?)GiA|6TndJ+~^eXL<%D)IcZ)QT?t zE7BJP>Ejq;`w$<dd^@|esR(;1Z@9EVR%7cZG`%Xr%6 zLHXY#GmPV!HIO3@j5yf7D{PN5E6tHni4mC;qIq0Fj_fE~F1XBdnzZIRlk<~?V{-Uc zt9ldgjf)@8NoAK$6OR|2is_g&pSrDGlQS);>YwV7C!=#zDSwF}{_1#LA*~RGwALm) zC^N1ir5_}+4!)@;uj92irB5_Ugihk&Uh|VHd924V{MiY7NySDh z|6TZCb1g`c)w{MWlMFM5NK@xF)M33F$ZElj@}kMu$icMyba8UlNQ86~I$sau*1pzZ z4P)NF@3(jN(thO5jwkx(M5HOe)%P1~F!hXMr%Rp$&OY0X{l_froFdbi(jCNHbHj#! z(G`_tuGxu#h@C9HlIQ8BV4>%8eN=MApyiPE0B3dR`bsa1=MM$lp+38RN4~`m>PkE? zARywuzZ#nV|0wt;22|ITkkrt>ahz7`sKXd2!vpFCC4i9VnpNvmqseE%XnxofI*-Mr6tjm7-3$I-v}hr6B($ALZ=#Q4|_2l#i5JyVQCE{hJAnFhZF>vfSZgnw`Vgn zIi{y#1e7`}xydrUAdXQ%e?_V6K(DK89yBJ;6Sf{Viv*GzER9C3Mns=nTFt6`Eu?yu<*Fb}WpP$iO#-y+^H>OQ< zw%DSM@I=@a)183hx!sz(#&cg-6HVfK(UMgo8l2jynx5RWEo8`?+^3x0sEoj9H8%m1 z87?l+w;0=@Dx_J86rA6vesuDQ^nY(n?SUdaY}V)$Tvr%>m9XV>G>6qxKxkH zN6|PyTD(7+fjtb}cgW1rctvZQR!3wX2S|ils!b%(=jj6lLdx#rjQ6XuJE1JhNqzXO zKqFyP8Y1tN91g;ahYsvdGsfyUQz6$HMat!7N1mHzYtN3AcB>par(Q>mP7^`@7@Ox14gD12*4RISSYw-L>xO#HTRgM)eLaOOFuN}_UZymIhu%J?D|k>Y`@ zYxTvA;=QLhu@;%L6;Ir_$g+v3;LSm8e3sB;>pI5QG z{Vl6P-+69G-P$YH-yr^3cFga;`e4NUYzdQy6vd|9${^b#WDUtxoNe;FCcl5J7k*KC z7JS{rQ1%=7o8to#i-`FD3C?X3!60lDq4CqOJ8%iRrg=&2(}Q95QpU_q ziM346!4()C$dHU@LtBmfKr!gZGrZzO{`dm%w_L1DtKvh8UY zTP3-|50~Xjdu9c%Cm!BN^&9r?*Wgd(L@E!}M!#`C&rh&c2fsGJ_f)XcFg~$#3S&Qe z_%R=Gd`59Qicu`W5YXk>vz5!qmn`G>OCg>ZfGGuI5;yQW9Kg*exE+tdArtUQfZ&kO ze{h37fsXuQA2Z(QW|un!G2Xj&Qwsk6FBRWh;mfDsZ-$-!YefG!(+bY#l3gFuj)OHV830Xl*NKp1-L&NPA3a8jx#yEn3>wea~ z9zp8G6apWn$0s)Pa!TJo(?lHBT1U4L>82jifhXlkv^a+p%a{Og8D?k6izWyhv`6prd7Yq5{AqtzA8n{?H|LeQFqn(+fiIbDG zg_E<1t%>753QV!erV^G4^7p1SE7SzIqBwa{%kLHzP{|6_rlM*ae{*y4WO?{%&eQ`| z>&}ZkQ;<)rw;d(Dw*om?J@3<~UrXsvW2*0YOq_-Lfq45PQGUVu?Ws3&6g$q+q{mx4 z$2s@!*|A+74>QNlK!D%R(u22>Jeu}`5dsv9q~VD!>?V86x;Fg4W<^I;;ZEq5z4W5c z#xMX=!iYaaW~O<(q>kvxdjNk15H#p0CSmMaZB$+%v90@w(}o$T7;(B+Zv%msQvjnW z`k7=uf(h=gkivBw?57m%k^SPxZnYu@^F% zKd`b)S#no`JLULZCFuP^y5ViChc;^3Wz#c|ehD+2MHbUuB3IH5+bJ_FChTdARM6Q2 zdyuu9eX{WwRasK!aRXE+0j zbTS8wg@ue{fvJ*=KtlWbrXl8YP88;GXto?_h2t@dY3F?=gX9Frwb8f1n!^xdOFDL7 zbddq6he>%k+5?s}sy?~Ya!=BnwSDWloNT;~UF4|1>rUY!SSl^*F6NRs_DT-rn=t-p z_Ga0p)`@!^cxW_DhPA=0O;88pCT*G9YL29_4fJ(b{| zuR~VCZZCR97e%B(_F5^5Eifes$8!7DCO_4(x)XZDGO%dY9Pkm~-b1-jF#2H4kfl<3 zsBes0sP@Zyon~Q&#<7%gxK{o+vAsIR>gOm$w+{VY8ul7OsSQ>07{|7jB6zyyeu+WU zME>m2s|$xvdsY^K%~nZ^%Y`D7^PCO(&)eV-Qw|2_PnL=Nd=}#4kY)PS=Y62Dzz1e2 z&*)`$OEBuC&M5f`I}A-pEzy^lyEEcd$n1mEgLj}u_b^d!5pg{v+>_FexoDxYj%X_F z5?4eHVXurS%&n2ISv2&Eik?@3ry}0qCwS9}N)`Zc_Q8}^SOViB_AB&o6Eh#bG;NnL zAhP2ZF_la`=dZv6Hs@78DfMjy*KMSExRZfccK=-DPGkqtCK%U1cUXxbTX-I0m~x$3 z&Oc&aIGWtcf|i~=mPvR^u6^&kCj|>axShGlPG}r{DyFp(Fu;SAYJ}9JfF*x0k zA@C(i5ZM*(STcccXkpV$=TznZKQVtec!A24VWu*oS0L(^tkEm2ZIaE4~~?#y9Z4 zlU!AB6?yc(jiB`3+{FC zl|IdP1Fdt#e5DI{W{d8^$EijTU(8FA@8V&_A*tO?!9rI zhoRk`Q*riCozP>F%4pDPmA>R#Zm>_mAHB~Y5$sE4!+|=qK0dhMi4~`<6sFHb=x8Naml}1*8}K_Es3#oh3-7@0W}BJDREnwWmw<{wY9p)3+Mq2CLcX?uAvItguqhk*Po!RoP`kR)!OQy3Ayi zL@ozJ!I_F2!pTC?OBAaOrJmpGX^O(dSR-yu5Wh)f+o5O262f6JOWuXiJS_Jxgl@lS z6A9c*FSHGP4HuwS)6j3~b}t{+B(dqG&)Y}C;wnb!j#S0)CEpARwcF4Q-5J1NVizx7 z(bMG>ipLI1lCq?UH~V#i3HV9|bw%XdZ3Q#c3)GB+{2$zoMAev~Y~(|6Ae z^QU~3v#*S>oV*SKvA0QBA#xmq9=IVdwSO=m=4Krrlw>6t;Szk}sJ+#7=ZtX(gMbrz zNgv}8GoZ&$=ZYiI2d?HnNNGmr)3I);U4ha+6uY%DpeufsPbrea>v!D50Q)k2vM=aF-zUsW*aGLS`^2&YbchmKO=~eX@k9B!r;d{G% zrJU~03(->>utR^5;q!i>dAt)DdR!;<9f{o@y2f}(z(e)jj^*pcd%MN{5{J=K<@T!z zseP#j^E2G31piu$O@3kGQ{9>Qd;$6rr1>t!{2CuT_XWWDRfp7KykI?kXz^{u_T2AZ z-@;kGj8Iy>lOcUyjQqK!1OHkY?0Kz+_`V8$Q-V|8$9jR|%Ng;@c%kF_!rE3w>@FtX zX1w7WkFl%Vg<mE0aAHX==DLjyxlfA}H|LVh;}qcWPd8pSE!_IUJLeGAW#ZJ?W}V7P zpVeo|`)a<#+gd}dH%l)YUA-n_Vq3*FjG1}6mE;@A5ailjH*lJaEJl*51J0)Xecn6X zz zDr~lx5`!ZJ`=>>Xb$}p-!3w;ZHtu zX@xB4PbX!J(Jl((<8K%)inh!-3o2S2sbI4%wu9-4ksI2%e=uS?Wf^Tp%(Xc&wD6lV z*DV()$lAR&##AVg__A=Zlu(o$3KE|N7ZN{X8oJhG+FYyF!(%&R@5lpCP%A|{Q1cdr>x0<+;T`^onat<6tlGfEwRR?ZgMTD-H zjWY?{Fd8=Fa6&d@0+pW9nBt-!muY@I9R>eD5nEDcU~uHUT04gH-zYB>Re+h4EX|IH zp`Ls>YJkwWD3+}DE4rC3kT-xE89^K@HsCt6-d;w*o8xIHua~||4orJ<7@4w_#C6>W z2X$&H38OoW8Y-*i=@j*yn49#_C3?@G2CLiJUDzl(6P&v`lW|=gQ&)DVrrx8Bi8I|$ z7(7`p=^Lvkz`=Cwd<0%_jn&6k_a(+@)G^D04}UylQax*l(bhJ~;SkAR2q*4>ND5nc zq*k9(R}Ijc1J8ab>%Tv{kb-4TouWfA?-r(ns#ghDW^izG3{ts{C7vHc5Mv?G;)|uX zk&Fo*xoN`OG9ZXc>9(`lpHWj~9!hI;2aa_n!Ms1i;BFHx6DS23u^D^e(Esh~H@&f}y z(=+*7I@cUGi`U{tbSUcSLK`S)VzusqEY)E$ZOokTEf2RGchpmTva?Fj! z<7{9Gt=LM|*h&PWv6Q$Td!|H`q-aMIgR&X*;kUHfv^D|AE4OcSZUQ|1imQ!A$W)pJtk z56G;0w?&iaNV@U9;X5?ZW>qP-{h@HJMt;+=PbU7_w`{R_fX>X%vnR&Zy1Q-A=7**t zTve2IO>eEKt(CHjSI7HQ(>L5B5{~lPm91fnR^dEyxsVI-wF@82$~FD@aMT%$`usqNI=ZzH0)u>@_9{U!3CDDC#xA$pYqK4r~9cc_T@$nF1yODjb{=(x^({EuO?djG1Hjb{u zm*mDO(e-o|v2tgXdy87*&xVpO-z_q)f0~-cf!)nb@t_uCict?p-L%v$_mzG`FafIV zPTvXK4l3T8wAde%otZhyiEVVU^5vF zQSR{4him-GCc-(U;tIi;qz1|Az0<4+yh6xFtqB-2%0@ z&=d_5y>5s^NQKAWu@U#IY_*&G73!iPmFkWxxEU7f9<9wnOVvSuOeQ3&&HR<>$!b%J z#8i?CuHx%la$}8}7F5-*m)iU{a7!}-m@#O}ntat&#d4eSrT1%7>Z?A-i^Y!Wi|(we z$PBfV#FtNZG8N-Ot#Y>IW@GtOfzNuAxd1%=it zDRV-dU|LP#v70b5w~fm_gPT6THi zNnEw&|Yc9u5lzTVMAL} zgj|!L&v}W(2*U^u^+-e?Tw#UiCZc2omzhOf{tJX*;i2=i=9!kS&zQN_hKQ|u7_3vo6MU0{U+h~` zckXGO+XK9{1w3Z$U%%Fw`lr7kK8PzU=8%0O8ZkW`aQLFlR4OCb^aQgGCBqu6AymXk zX!p(JDJtR`xB$j48h}&I2FJ*^LFJzJQJ0T>=z{*> zWesZ#%W?fm`?f^B^%o~Jzm|Km5$LP#d7j9a{NCv!j14axHvO<2CpidW=|o4^a|l+- zSQunLj;${`o%xrlcaXzOKp>nU)`m{LuUW!CXzbyvn;MeK#-D{Z4)+>xSC)km=&K%R zsXs3uRkta6-rggb8TyRPnquv1>wDd)C^9iN(5&CEaV9yAt zM+V+%KXhGDc1+N$UNlgofj8+aM*(F7U3=?grj%;Pd+p)U9}P3ZN`}g3`{N`bm;B(n z12q1D7}$``YQC7EOed!n5Dyj4yl~s0lptb+#IEj|!RMbC!khpBx!H-Kul(_&-Z^OS zQTSJA@LK!h^~LG@`D}sMr2VU#6K5Q?wqb7-`ct2(IirhhvXj?(?WhcNjJiPSrwL0} z8LY~0+&7<~&)J!`T>YQgy-rcn_nf+LjKGy+w+`C*L97KMD%0FWRl`y*piJz2=w=pj zxAHHdkk9d1!t#bh8Joi1hTQr#iOmt8v`N--j%JaO`oqV^tdSlzr#3 zw70~p)P8lk<4pH{_x$^i#=~E_ApdX6JpR`h{@<Y;PC#{0uBTe z1Puhl^q=DuaW}Gdak6kV5w);35im0PJ0F)Zur)CI*LXZxZQTh=4dWX}V}7mD#oMAn zbxKB7lai}G8C){LS`hn>?4eZFaEw-JoHI@K3RbP_kR{5eyuwBL_dpWR>#bo!n~DvoXvX`ZK5r|$dBp6%z$H@WZ6Pdp&(zFKGQ z2s6#ReU0WxOLti@WW7auSuyOHvVqjaD?kX;l)J8tj7XM}lmLxLvp5V|CPQrt6ep+t z>7uK|fFYALj>J%ou!I+LR-l9`z3-3+92j2G`ZQPf18rst;qXuDk-J!kLB?0_=O}*XQ5wZMn+?ZaL5MKlZie- z0aZ$*5~FFU*qGs|-}v-t5c_o-ReR@faw^*mjbMK$lzHSheO*VJY)tBVymS^5ol=ea z)W#2z8xCoh1{FGtJA+01Hwg-bx`M$L9Ex-xpy?w-lF8e*xJXS4(I^=k1zFy|V)=ll z#&yez3hRC5?@rPywJo2eOHWezUxZphm#wo`oyA-sP@|^+LV0^nzq|UJEZZM9wqa z5Y}M0Lu@0Qd%+Q=3kCSb6q4J60t_s(V|qRw^LC>UL7I`=EZ zvIO;P2n27=QJ1u;C+X)Si-P#WB#phpY3XOzK(3nEUF7ie$>sBEM3=hq+x<=giJjgS zo;Cr5uINL%4k@)X%+3xvx$Y09(?<6*BFId+399%SC)d# zk;Qp$I}Yiytxm^3rOxjmRZ@ws;VRY?6Bo&oWewe2i9Kqr1zE9AM@6+=Y|L_N^HrlT zAtfnP-P8>AF{f>iYuKV%qL81zOkq3nc!_?K7R3p$fqJ?};QPz6@V8wnGX>3%U%$m2 zdZv|X+%cD<`OLtC<>=ty&o{n-xfXae2~M-euITZY#X@O}bkw#~FMKb5vG?`!j4R_X%$ZSdwW zUA0Gy&Q_mL5zkhAadfCo(yAw1T@}MNo>`3Dwou#CMu#xQKY6Z+9H+P|!nLI;4r9@k zn~I*^*4aA(4y^5tLD+8eX;UJW;>L%RZZUBo(bc{)BDM!>l%t?jm~}eCH?OOF%ak8# z*t$YllfyBeT(9=OcEH(SHw88EOH0L1Ad%-Q`N?nqM)<`&nNrp>iEY_T%M6&U>EAv3 zMsvg1E#a__!V1E|ZuY!oIS2BOo=CCwK1oaCp#1ED_}FGP(~Xp*P5Gu(Pry_U zm{t$qF^G^0JBYrbFzPZkQ;#A63o%iwe;VR?*J^GgWxhdj|tj`^@i@R+vqQWt~^ z-dLl-Ip4D{U<;YiFjr5OUU8X^=i35CYi#j7R! zI*9do!LQrEr^g;nF`us=oR2n9ei?Gf5HRr&(G380EO+L6zJD)+aTh_<9)I^{LjLZ} z{5Jw5vHzucQ*knJ6t}Z6k+!q5a{DB-(bcN*)y?Sfete7Y}R9Lo2M|#nIDsYc({XfB!7_Db0Z99yE8PO6EzLcJGBlHe(7Q{uv zlBy7LR||NEx|QyM9N>>7{Btifb9TAq5pHQpw?LRe+n2FV<(8`=R}8{6YnASBj8x}i zYx*enFXBG6t+tmqHv!u~OC2nNWGK0K3{9zRJ(umqvwQ~VvD;nj;ihior5N$Hf@y0G z$7zrb=CbhyXSy`!vcXK-T}kisTgI$8vjbuCSe7Ev*jOqI&Pt@bOEf>WoQ!A?`UlO5 zSLDKE(-mN4a{PUu$QdGbfiC)pA}phS|A1DE(f<{Dp4kIB_1mKQ5!0fdA-K0h#_ z{qMsj@t^!n0Lq%)h3rJizin0wT_+9K>&u0%?LWm<{e4V8W$zZ1w&-v}y zY<6F2$6Xk>9v{0@K&s(jkU9B=OgZI(LyZSF)*KtvI~a5BKr_FXctaVNLD0NIIokM}S}-mCB^^Sgqo%e{4!Hp)$^S%q@ zU%d&|hkGHUKO2R6V??lfWCWOdWk74WI`xmM5fDh+hy6>+e)rG_w>_P^^G!$hSnRFy z5fMJx^0LAAgO5*2-rsN)qx$MYzi<_A=|xez#rsT9&K*RCblT2FLJvb?Uv3q^@Dg+J zQX_NaZza4dAajS!khuvt_^1dZzOZ@eLg~t02)m2+CSD=}YAaS^Y9S`iR@UcHE%+L0 zOMR~6r?0Xv#X8)cU0tpbe+kQ;ls=ZUIe2NsxqZFJQj87#g@YO%a1*^ zJZ+`ah#*3dVYZdeNNnm8=XOOc<_l-b*uh zJR8{yQJ#-FyZ!7yNxY|?GlLse1ePK!VVPytKmBwlJdG-bgTYW$3T5KinRY#^Cyu@& zd7+|b@-AC67VEHufv=r5(%_#WwEIKjZ<$JD%4!oi1XH65r$LH#nHHab{9}kwrjtf= zD}rEC65~TXt=5bg*UFLw34&*pE_(Cw2EL5Zl2i^!+*Vx+kbkT_&WhOSRB#8RInsh4 z#1MLczJE+GAHR^>8hf#zC{pJfZ>6^uGn6@eIxmZ6g_nHEjMUUfXbTH1ZgT7?La;~e zs3(&$@4FmUVw3n033!1+c9dvs&5g#a;ehO(-Z}aF{HqygqtHf=>raoWK9h7z)|DUJ zlE0#|EkzOcrAqUZF+Wd@4$y>^0eh!m{y@qv6=C zD(){00vE=5FU@Fs_KEpaAU1#$zpPJGyi0!aXI8jWaDeTW=B?*No-vfv=>`L`LDp$C zr4*vgJ5D2Scl{+M;M(#9w_7ep3HY#do?!r0{nHPd3x=;3j^*PQpXv<~Ozd9iWWlY_ zVtFYzhA<4@zzoWV-~in%6$}Hn$N;>o1-pMK+w$LaN1wA95mMI&Q6ayQO9 zTq&j)LJm4xXjRCse?rMnbm%7E#%zk!EQiZwt6gMD=U6A0&qXp%yMa(+C~^(OtJ8dH z%G1mS)K9xV9dlK>%`(o6dKK>DV07o46tBJfVxkIz#%VIv{;|)?#_}Qq(&| zd&;iIJt$|`te=bIHMpF1DJMzXKZp#7Fw5Q0MQe@;_@g$+ELRfh-UWeYy%L*A@SO^J zLlE}MRZt(zOi6yo!);4@-`i~q5OUAsac^;RpULJD(^bTLt9H{0a6nh0<)D6NS7jfB ze{x#X2FLD2deI8!#U@5$i}Wf}MzK&6lSkFy1m2c~J?s=!m}7%3UPXH_+2MnKNY)cI z(bLGQD4ju@^<+%T5O`#77fmRYxbs(7bTrFr=T@hEUIz1t#*ntFLGOz)B`J&3WQa&N zPEYQ;fDRC-nY4KN`8gp*uO@rMqDG6=_hHIX#u{TNpjYRJ9ALCl!f%ew7HeprH_I2L z6;f}G90}1x9QfwY*hxe&*o-^J#qQ6Ry%2rn=9G3*B@86`$Pk1`4Rb~}`P-8^V-x+s zB}Ne8)A3Ex29IIF2G8dGEkK^+^0PK36l3ImaSv1$@e=qklBmy~7>5IxwCD9{RFp%q ziejFT(-C>MdzgQK9#gC?iFYy~bjDcFA^%dwfTyVCk zuralB)EkA)*^8ZQd8T!ofh-tRQ#&mWFo|Y3taDm8(0=KK>xke#KPn8yLCXwq zc*)>?gGKvSK(}m0p4uL8oQ~!xRqzDRo(?wvwk^#Khr&lf9YEPLGwiZjwbu*p+mkWPmhoh0Fb(mhJEKXl+d68b6%U{E994D z3$NC=-avSg7s{si#CmtfGxsijK_oO7^V`s{?x=BsJkUR4=?e@9# z-u?V8GyQp-ANr%JpYO;3gxWS?0}zLmnTgC66NOqtf*p_09~M-|Xk6ss7$w#kdP8`n zH%UdedsMuEeS8Fq0RfN}Wz(IW%D%Tp)9owlGyx#i8YZYsxWimQ>^4ikb-?S+G;HDT zN4q1{0@|^k_h_VFRCBtku@wMa*bIQc%sKe0{X@5LceE`Uqqu7E9i9z-r}N2ypvdX1{P$*-pa$A8*~d0e5AYkh_aF|LHt7qOX>#d3QOp-iEO7Kq;+}w zb)Le}C#pfmSYYGnq$Qi4!R&T{OREvbk_;7 zHP<*B$~Qij1!9Me!@^GJE-icH=set0fF-#u5Z{JmNLny=S*9dbnU@H?OCXAr7nHQH zw?$mVH^W-Y89?MZo5&q{C2*lq}sj&-3@*&EZaAtpxiLU==S@m_PJ6boIC9+8fKz@hUDw==nNm9? z`#!-+AtyCOSDPZA)zYeB|EQ)nBq6!QI66xq*PBI~_;`fHEOor}>5jj^BQ;|-qS5}1 zRezNBpWm1bXrPw3VC_VHd z$B06#uyUhx)%6RkK2r8*_LZ3>-t5tG8Q?LU0Yy+>76dD(m|zCJ>)}9AB>y{*ftDP3 z(u8DDZd(m;TcxW-w$(vq7bL&s#U_bsIm67w{1n|y{k9Ei8Q9*8E^W0Jr@M?kBFJE< zR7Pu}#3rND;*ulO8X%sX>8ei7$^z&ZH45(C#SbEXrr3T~e`uhVobV2-@p5g9Of%!f z6?{|Pt*jW^oV0IV7V76Pd>Pcw5%?;s&<7xelwDKHz(KgGL7GL?IZO%upB+GMgBd3ReR9BS zL_FPE2>LuGcN#%&=eWWe;P=ylS9oIWY)Xu2dhNe6piyHMI#X4BFtk}C9v?B3V+zty zLFqiPB1!E%%mzSFV+n<(Rc*VbvZr)iJHu(HabSA_YxGNzh zN~O(jLq9bX41v{5C8%l%1BRh%NDH7Vx~8nuy;uCeXKo2Do{MzWQyblZsWdk>k0F~t z`~8{PWc86VJ)FDpj!nu))QgHjl7a%ArDrm#3heEHn|;W>xYCocNAqX{J(tD!)~rWu zlRPZ3i5sW;k^^%0SkgV4lypb zqKU2~tqa+!Z<)!?;*50pT&!3xJ7=7^xOO0_FGFw8ZSWlE!BYS2|hqhQT8#x zm2a$OL>CiGV&3;5-sXp>3+g+|p2NdJO>bCRs-qR(EiT&g4v@yhz(N5cU9UibBQ8wM z0gwd4VHEs(Mm@RP(Zi4$LNsH1IhR}R7c9Wd$?_+)r5@aj+!=1-`fU(vr5 z1c+GqAUKulljmu#ig5^SF#{ag10PEzO>6fMjOFM_Le>aUbw>xES_Ow|#~N%FoD{5!xir^;`L1kSb+I^f z?rJ0FZugo~sm)@2rP_8p$_*&{GcA4YyWT=!uriu+ZJ%~_OD4N%!DEtk9SCh+A!w=< z3af%$60rM%vdi%^X2mSb)ae>sk&DI_&+guIC88_Gq|I1_7q#}`9b8X zGj%idjshYiq&AuXp%CXk>zQ3d2Ce9%-?0jr%6-sX3J{*Rgrnj=nJ2`#m`TaW-13kl zS2>w8ehkYEx@ml2JPivxp zIa2l^?)!?Y*=-+jk_t;IMABQ5Uynh&LM^(QB{&VrD7^=pXNowzD9wtMkH_;`H|d0V z*rohM)wDg^EH_&~=1j1*?@~WvMG3lH=m#Btz?6d9$E*V5t~weSf4L%|H?z-^g>Fg` zI_Q+vgHOuz31?mB{v#4(aIP}^+RYU}^%XN}vX_KN=fc{lHc5;0^F2$2A+%}D=gk-) zi1qBh!1%xw*uL=ZzYWm-#W4PV(?-=hNF%1cXpWQ_m=ck1vUdTUs5d@2Jm zV8cXsVsu~*f6=_7@=1 zaV0n2`FeQ{62GMaozYS)v~i10wGoOs+Z8=g$F-6HH1qBbasAkkcZj-}MVz{%xf8`2 z1XJU;&QUY4Hf-I(AG8bX zhu~KqL}TXS6{)DhW=GFkCzMFMSf`Y00e{Gzu2wiS4zB|PczU^tjLhOJUv=i2KuFZHf-&`wi>CU0h_HUxCdaZ`s9J8|7F}9fZXg`UUL}ws7G=*n zImEd-k@tEXU?iKG#2I13*%OX#dXKTUuv1X3{*WEJS41ci+uy=>30LWCv*YfX_A2(M z9lnNAjLIzX=z;g;-=ARa<`z$x)$PYig1|#G;lnOs8-&rB2lT0#e;`EH8qZ_xNvwy7 zo_9>P@SHK(YPu*8r86f==eshYjM3yAPOHDn- zmuW04o02AGMz!S|S32(h560d(IP$;S7LIM(PC7Owwr$&XCbsQNY))+3HYS+ZcHTVq zJm;QsfA`#~_m8fwuI~DFb$@pE-h1t}*HZB7hc-CUM~x6aZ<4v9_Jr-))=El>(rphK z(@wMC$e>^o+cQ(9S+>&JfP;&KM6nff2{RNu;MqE9>L9t^lvzo^*B5>@$TG!gZlh0Z z%us8ys$1~v&&N-gPBvXl5b<#>-@lhAkg_4Ev6#R&r{ObIn=Qki&`wxR_OWj%kU_RW&w#Mxv%x zW|-sJ^jss+;xmxi8?gphNW{^HZ!xF?poe%mgZ>nwlqgvH@TrZ zad5)yJx3T|&$Afl$pkh=7bZAwBdv+tQEP=d3vE#o<&r6h+sTU$64ZZQ0e^Fu9FrnL zN-?**4ta&!+{cP=jt`w)5|dD&CP@-&*BsN#mlbUn!V*(E_gskcQ*%F#Nw#aTkp%x| z8^&g)1d!%Y+`L!Se2s_XzKfonT_BWbn}LQo#YUAx%f7L__h4Xi680GIk)s z8GHm59EYn(@4c&eAO)}0US@((t#0+rNZ680SS<=I^|Y=Yv)b<@n%L20qu7N%V1-k1 z*oxpOj$ZAc>L6T)SZX?Pyr#}Q?B`7ZlBrE1fHHx_Au{q9@ zLxwPOf>*Gtfv6-GYOcT^ZJ7RGEJTVXN=5(;{;{xAV3n`q1Z-USkK626;atcu%dTHU zBewQwrpcZkKoR(iF;fVev&D;m9q)URqvKP*eF9J=A?~0=jn3=_&80vhfBp?6@KUpgyS`kBk(S0@X5Xf%a~?#4Ct5nMB9q~)LP<`G#T-eA z+)6cl1H-2uMP=u<=saDj*;pOggb2(NJO^pW8O<6u^?*eiqn7h)w9{D`TrE1~k?Xuo z(r%NIhw3kcTHS%9nbff>-jK1k^~zr8kypQJ6W+?dkY7YS`Nm z5i;Q23ZpJw(F7|e?)Tm~1bL9IUKx6GC*JpUa_Y00Xs5nyxGmS~b{ zR!(TzwMuC%bB8&O->J82?@C|9V)#i3Aziv7?3Z5}d|0eTTLj*W3?I32?02>Eg=#{> zpAO;KQmA}fx?}j`@@DX-pp6{-YkYY81dkYQ(_B88^-J#rKVh8Wys-;z)LlPu{B)0m zeZr=9{@6=7mrjShh~-=rU}n&B%a7qs1JL_nBa>kJFQ8elV=2!WY1B5t2M5GD5lt|f zSAvTgLUv#8^>CX}cM(i(>(-)dxz;iDvWw5O!)c5)TBoWp3$>3rUI=pH9D1ffeIOUW zDbYx}+)$*+`hT}j226{;=*3(uc*ge(HQpTHM4iD&r<=JVc1(gCy}hK%<(6)^`uY4>Tj6rIHYB zqW5UAzpdS!34#jL;{)Fw{QUgJ~=w`e>PHMsnS1TcIXXHZ&3M~eK5l>Xu zKsoFCd%;X@qk#m-fefH;((&?Y9grF{Al#55A3~L5YF0plJ;G=;Tr^+W-7|6IO;Q+8 z(jAXq$ayf;ZkMZ4(*w?Oh@p8LhC6=8??!%@V(e}%*>fW^Gdn|qZVyvHhcn;7nP7e; z13!D$^-?^#x*6d1)88ft06hVZh%m4w`xR?!cnzuoOj(g9mdE2vbKT@RghJ)XOPj{9 z@)8!#=HRJvG=jDJ77XND;cYsC=CszC!<6GUC=XLuTJ&-QRa~EvJ1rk2+G!*oQJ-rv zDyHVZ{iQN$*5is?dNbqV8|qhc*O15)HGG)f2t9s^Qf|=^iI?0K-Y1iTdr3g=GJp?V z$xZiigo(pndUv;n1xV1r5+5qPf#vQQWw3m&pRT>G&vF( zUfKIQg9%G;R`*OdO#O;nP4o+BElMgmKt<>DmKO1)S$&&!q6#4HnU4||lxfMa-543{ zkyJ+ohEfq{OG3{kZszURE;Rw$%Q;egRKJ%zsVcXx!KIO0*3MFBx83sD=dDVsvc17i zIOZuEaaI~q`@!AR{gEL#Iw}zQpS$K6i&omY2n94@a^sD@tQSO(dA(npgkPs7kGm>;j?$Ia@Q-Xnzz?(tgpkA6VBPNX zE?K%$+e~B{@o>S+P?h6K=XP;caQ=3)I{@ZMNDz)9J2T#5m#h9nXd*33TEH^v7|~i) zeYctF*06eX)*0e{xXaPT!my1$Xq>KPJakJto3xnuT&z zSaL8NwRUFm?&xIMwA~gt4hc3=hAde#vDjQ!I)@;V<9h2YOvi-XzleP!g4blZm|$iV zF%c3G8Cs;FH8|zEczqGSY%F54h`$P_VsmJ6TaXRLc8lSf`Sv%s%6<4+;Wbs-3lya( z=9I>I%97Y~G945O48YaAq6ENPUs%EJvyC! zM4jMgJj}r~@D;cdaQ-j#`5zCRku}42aI<>CgraXuKDr19db~#|@UyM;f-uc!(KDsu z5EA@CsN>^t@oH+0!SALi;ud>`P5mQta+Lh*-#RHJ)Gin%>EaFLSoU`(TG7c|yeFvl zk|Yll%)h-*%WoI6M*j+4xw`OqiDVX{k-^V2{rzCIM9mzNHGP^D={!*P7T)%yDSI5- zkGA4}r3`)#Vl6JFJ3xG)8K;FTtII9o7jNHof_Z_Zc<%@-H4RPpyXudpf)ky zmTH$LFGxaIUGQ;l=>R>?+>ZSCU|@&+Gt@5Bj3w{L{KPpgQ<~)jqx0oNZSv9R&^A42 zzqJr?C#D-n>=9FjM=D=7h_$QO$KQ8*%0%)rI(Npai_JjE9_lBk75BQMI zkk4X5PATWgrub!fb5Hxi8{(Y<(GOO8^HECOA)eanyS{u%leQOkp;1W}_8eH?nPQxW zd#Z+uJfTK>g-TR3WPu~2Ru9A+NkuIICM@PyPmJn(GBZt;xFZNDMbw8`xzl2`(?UC- z#<*=*fo{UOvycb|b&4y0Nm!sHhFMI*Y$Olgh;BG#xBU+yxav82Ejj(ZvQ|64Wwy7I zN=DXx7(V^NTH3YRB4HOu6T5=DW86P`L#Ng!SuT{%&>Cq8>|o8lF^^U%MRU41TT?h& z!uJ$YdbM*2y?#`LJ2)XPoKq`hm$I3R{V5-;@u7!E9tH4sR(`Ab-Qh!|UN-a5fZ?P@2LWRvSv!hOk08;Yy!h&uEI-X}j+&v`X` zkqY%*F@{}DHL*Jgjg2}a54hwEV`63bK4>mL%D^YT|>m1-kX{876BRm&`Y#{$&oz($qWJL}T*tj42k+yu8fa=4b7VUPq()Wb~=L?DU0U-4*Iu^KMZBRByWn-@=_f(4){Or#| zpw}~Ajs6a=z!8_H59lqYlfnS77QY0pHpIz0#)}!EGhypupZeZe@%cv z6Dngnl*SsUy^a`v?>lARi6Yps@%32JpGQvrcd*A8LPLEInBEU2vriGvMqG!jh^=Gj zXvu5zpikqnt*e4&Un_e$2FAB?(yOS0JAzxh@nN?Blqc-)Pv`U}&E5|# z)97-9utpqi*`hR+$;eS)A+KK)CO)V`b?*}z&*+28mDfWI31)sF)tBg6LVlxS z225poL+O|x)5;skkj{rew<}TsDVqFMMLSgd;UK7^clMcObM~IgSq6!eJ($JP!KHPr zBJ&SHi{wLsgMzn1^#kV#_!NO@RG@B5lxBO7WfIAi@o`{_XQg(*{R=@Z(0ij+*i7sK zW5D%_fRN7l6qpytW2K1lUqP&W5jDT!AA9@q<;M!T=CKv*^MP)Er_uLL+Y53>**w7Y zQ!2?^4$wC;Soc!+#~d?Yec;NLdR z{~*hrSQS>UOMBe)1pHe0EsyO@d(IrU4ZiS&jL`wqv6Oqv=HbI^70qu9kn~wGkNL^> z!Pd2)i--+&zp^`#4@*Myg;3r(jt*h@RWgRt70byZr;0Na8n4!bmpuX1&gK=QK!@j< zH2fF7@2s0H0!9%VC-BIp(99@e@<%Ko?BB9uv*xPnZ5dQr z8r7~9cZXv(AZPY^<(X@}GARv&_}mfYA7`vdl=)g2GIyN(<}(b_S_N2--NKp$SgO<3 zRx|EabcjUSB44GaH3Kxmx3SW;E;Eia2Zs5SkbkQ8E%VQqr0J?tQjF~p;nbIXn+D;? zg;t3Jg7A@9U**@aaqs}9;%??Scm{zBIY2ceYAQd*W-hB-!+H&4#yrm*GtT*&#`FXx zGIVm}G<;Pj+h*KQ68S4rcIIGw-mkl039s@O4p9F%TC&&&xRL=N49v2PdBb$MxJoMo zQk8+Sv+F5m{xP1prZvn1=x-Q z&Yox|y&arZrLTm~<%o}VfPV#z+i&{)W5emXhx^g~8>eUe)|Vvwp8-x8d-MOj%@mSk zZ9i{-Hu8m-rfO##y(_Rv;Y@?6%h4Id#6%`7ah+IaQ13o7o>bG&ScMj&KO~QoCmNT6()+oo%B zugV3Da)t>unQq=tbD)FP{JmB~S5QCmb)lq9Fp(*|(UGeXr3kR?k35sKFs{{a*y+h0anA_K@iCi;BR6nFmKHC=@)rMmu=XWS1nVqD*=#${cFJ6<{e=U7!Rbg>Y0b~d#&viX+5m9aNAv=RAMt8=n6a&@t^|2LsKMR7xF z;Cmw>t0<=W2II;doX`p#bcjPV9z&3dhAObzcB9xXMslqr(y!P6+2kG>Eh!rx&ZKmW)Wk~_xh`?neJqVhJk~1eTvRF#ehRwpS>s1{vUx*qf&Jm z$)Wh|lmwYatW@U@*$<14>^|yYwmwFs)C5ke9hG42{gilSU#^ulO`M}`wJ_4*-3 zGb?hfQj_AGQBI?4ghGijqfu>uAYkLK#!^uGUXuctdn8Ae5I7}o+j{9MJiM|sf9Nc{ zuP&Ls@?rMe=IfJo!=iX?9&*4!Yjs5d?0Yx4cIFXrkSHRk17Fc@yM__fyFLLl6O9nT zQqaDXunH;!PpQ7+-&#wJVtJXl8LjIkh)5qmcqhErYrP31w5~#!tS{LYTWGKEtbpE%(hH>qV(!2KMfs#a z?ZzzbDB}(7+NWIiSBQ<_{3>;H;z}uZI;n2PKWJNxM=l;5-^zpu-}+1x|38lS-}6GX z6F=M~bUtHg98X@of>mgCH-&5g6UpXGAla<+g`b&MQANW6D^;zfSzq0mQ)*J%;&tPOYin?J*G7GqmQ=>jvWvOn6E?! z{$(CU7}zChEnl$(>xf`ZdeF2E9Bv=eH&T4HWAOQ!9gBs z{gl^|(78q-ioBS^rR2PEGZLe_4Rl**H(bB?84RHquCEKi8N#29u=Eoh(DV`ZX{+8< z3BIX<`sOFNBziFWS#-X%(e`0C_|Q8;Pw9izjNOF8h|kvmWCmDHM&pANC9MV<wEJ;W{-jXqm!zC+Y@Q1y_lLL zfV^(1{A;L%TWmyI)RPknVUB<4r+d42S(W=%bXd@YB(~d>ABq-E;t)ie6%ouy(Fg`p zuj<=I7^PDs5H+UsG}+GH}zoGt*{yKF&n23C7aW@ z4ydrRtFW-uuAUu@RWe&0c!N4!H;`!n@@t#u zxlGQB4rx(F7#&MKHPy}EI;d+l(G{1KG!ZBE)7)@P!AsUCCCb0IH!P5TW=GoNFcif`NB4en16Cp<7=fhz7^uQAjbJBH>@naf2ueMktmtZ|U|)ICDMN2r`mgMSl=qDwHL;}L-d~El>pf8UJRts_03eTj*hVy6H z5o!>?AcffORZq9!NJNa`-W4wMfe6I{3*rYUhIMA>y|T}KZ56HR5XEs{(|x#SDtP@N z5?12L0W7qfvWl8T-V+u=fkBH8!$}g)7hRs34m7~)^S&Ar zd`Kz7$S2Mz(|5H(Dwn$V7n8K2pqhHQ8!i{G4C~Y6_Ex&Y%EyXdw#Nj}VdG`XCN_1n zFg4;3DGjjUo$%=m@ui%z$JU66QK^qywvLKZpD6ZQ2Ve2VBps8rcvJ6^Cf^#H4?UQ5PW$4;b)55yIY9}@k@48RLtJa>7bofX{EUE7 z?0Cx0PeYbbLAelC-BfqHf_08;{lzC1kwr|a>5{O6*g<~wt6KYPfP5uW0w?VTO!M~Q z6H@n{cONp`{>hVjEIkOV6m^ZP^l;mGz=T&*5&`m84astyZ#XZ6CpH384tt%vSJ zsvYDC5u`D&U_u)1OJ&D2=F*ie-7!%N+V6*qoM6m-zj|}hDZ+@?`mJ10OX3K-`+R0m zNk$^+zBJK7%It=_&sIc}&DT>!LYU{|WPNrp-Nfly8u5&3@(l{!pcPxek3^{L`<9*! zE-0KukkD^^+<&3BNJM$e0=~B$=VQEp@V`L+PsUEL-_%+E_kyR-_mUjr|D1Z2J->y2 zZNHTrzP$=uEKQvy4DG&+4*o5^8Kd?eI>5S#b;NXlSrGVnj3~e^OLe4*Qe7%U#4WiX z)k7h@VHRERR_j{wp8ALHdD6bj&+Dl^?2(MuL9*oTRUI3SQ2jJ4x#!GR~b8F(H6|clt%g_O=v(@*;;5eW{e)CsR{UNDIE{C-1@qe z7NY&S7DeI4?z7tR9LJ$e6za%qLsF(>%M?m1nQQ4htpl?P)yj7_C#Ds5k5F z1h@YlI%a#k9x6}=hs(mkRr-fSrmikEk)Iv6D`S==)-dDVbNK;4F@J7iC(M!K6l<^lm@iXKpYbd7b{_0BDjc9ju~tFH7Qfcgu>A9~3tzmbFnXbS(pWES9955Vbu=iI zX>GH$kbD_?_fRojp{~Mz+%=%RHG!3l(wxQb{zQlW&MTlbr2*9|peUBo#YZ8u!UMPz zJo9lmW3isPrkErmxp&SA4Z4vpe~LLL-w6JUW}f*bf#w6lVyDvUhdK9fX!p#TT3fL+ z7im|;28gcWM)UdfRI;603BWd`d%7#sP0t)qNW*R*WmrD?hg37Zngmu{P;Lm`rlK_> zITGMQH~V(}6l6}TeG5nPEHYI3EHiY}TD%AAQ@%&*Q@w}lLp!VC>E;PCjzgVyNqNmA zYd0t~-pn55?#)1Tc-(xbL07m;Md14bPJOLyoRpLhRx-BtH{Z%<78P>0$olxWy4d9! zncKIDHrWFnBRUUqc`qiz@xrz52u-?2kq~5n$h}&*K?MxJ?xV?vVXvLErROVl7L9s; zedsv`#k1PCWY;`{${N?=R9%uy1P+jKf$&__RLHP zWVH#4;U{}bB4D^B*hm%nhRpQF{4?xW$&|oNp2CUE?Coyj1QI%P|w91%+*lty%ecgZ$I1|mJWq9_c?+4{KElHR%TIU zf+^4^hXY?f0&(|Q5=NG~AhiIVR+(a1gF)Q;L&vH%zPO{yydKt*(f#LehU3CVRIS&* zA1khb+xXe{29|Ggayz;nqv9M8n$JYj?Z!w0Sb}^lq#XQlg~=nkBhYxmlB{huZcL}F zA6sNZgJpJ|laA>P$V#ZhT+&$nvNM2sudEEeUaohc#ab+sC zrj7G)E-#;G-w=I1hTjN@b;lAjX40pR+<>)=n`V_!(JFk*yE zP3nDEs^C9DCSbs8`TV~U17Bmq%9I^$2xWK;N>;W~^^HOu)jQt*LH(-WD@UyR?lk$o z+mZhVgYn<1!ov1;W|rozPKN*0V#Xxdelr-6M$Gf?*Y~BQbHRK-&@B;ni(p_#pe0mg z(1pQKcH#lqe^P^eZVUta>(kWOPSnhH^E-oKtcJzCI^FSuJ zze(PI3_%VP4Fp7k#GyT8c6l?vndL`$$s5Z05+P==upnazJ>&{eIc?MW6fVO34pXfm zmmilQmRYtQ*e*BV>J{aqI%F$j*;=Tdx{msYgM{2Gd`D^TU>~NLKrbqtQDh6KPGcB& zYEY{fj~P1Q zY_vIx8j+W?nOTo{k7|A!vvlK?qYKZnTkm@qV7lWQf#;J@)(qh~m07vHwdQ@701t>}N2> zYt=Q^?p;5oP%enrkvLCarS2rlJ;zjT@1)Ha_28t7T(IMcZi3U?D_dTzMKnR%{b7 zXeWL6f-xfJvhsVNF_?I2^3gmv=2|f7azO~wc+o|=2cR+N_<9sF;vio2z;vtlV7U6o z%q9XNPhjS1Fv)QuRq|0#HVGw&HG!!t0wQo=W>hP)uYZ7o;_qdM=-*`k-Z%4+>VGZ; z{vGL`lv&#q*NFJmy`%{yAIPrAB%*freDk*5cHaNPB~B86YH zIw9gNDz9H+n0&}J-c0V{E(`My-2Nkt0NBY-PjL5r*s48D&j)h7pIpJUb+0ol1F*~` zp1!}vw0*&IA^z*SXZ}pIG9;ySrW01 zpU6d%LB2t@(;)LD!*G(DXK-!R!}Bp1mKS>Uu`^#p z>~WR%dn&;>iuz9Pv3W7EPX~GtnCg$63a-#A$1B7q;ZqH{xws^Pf-V1eO|D zHXE9qC~c)%CS>n>jc?m)ux2hN2UpKIU2hP(X}`Ljjc|CDFH%asVJH&6j5&Rb6aaVeQvSt z6VIX1X(pXAmxL>}wO&QIImzI9LcFhECJ|Mzi1FWhCgS$=^!!D3^vyEEY0HM0>?fsv zz1W(i8*H{v9APY$IW@J9NQ06Y@g$&STTrPC$I1{t0ptDZ=rHjEZnN2BSw{(Pn+6KD zRZ-hjn-KgzRa=ZoUs=W0cAc-}66Rmi)kZgub$G6zPQn>fM&}9X6!J^UsbVFdewj#M zt5erf{g$1$WV`h=0<2Y%iDK|HwH6hSu-8LDPknW`jl$UfmI_z9=GkC(@A$oVsRFl` zMYdksp797E2vzaH-N_%;t@q4}Z;FxZ(y&6&(#;_uzaGV+M%CB= zVNRMN3tj1#%##v%wdYNDfy0)|Q$>JYJ8-6o*K4hcC(;5F=_Mn-l)y@UX$ zt$YU7Q%o3cqwRC6;{vbL1No%d&)=)2$$;SD9a-=PfFh$6P1;*I*d z?C_52JLp$(UF}SCxJXTY+9?uE`@f35}k=i`#4Rk6e@*KDc^(tnQcw(jY^fcG z2hqo(q%7)o0YkX;lCq$o6hgCi3n%i#6vZ7x&_k#aW{QnPk2CWm8yVytzz-Xd_05x& zK3Vo>SFs-R)cf&`{&tL=xJVe`-HvE7&mAL^uj`W z%$d@~HtC6RV)R6}b6PqR$Pa7R8c3d_D4Hqq2NfG(>kTi!rOp%>Lc~n3!5mddW>>pR zt8tmTCxnr(Xk6g2^MqN08AmxcFLP;APA}^V80R_+K#agUx(RR48L2ZQej@XRm?OF3 z&jyIH+L2f<&wdR}X$XB~;2tBIf^AThY(zLA4*i6@9FdbT!Xy~7Ywt-zdi=wCIRuOL z73^T>|0wMU6&500dh%`EqjoMKS;Z+_5iFfnaLNy+B-@vyNWRdcmRaaBUdtQvT_Q17 zTG$aE4SA0iRA}+d@r;k~BwsTn@=r*;LgW8Q~>>Y9oke1Rm(xx!gv){TQFv|25IK_jjLj z_mxH%0-WoyI`)361H|?QVmz7;GfF~EKrTLxMMI`-GF&@Hdq@W!)mBLYniN*qL^iti)BMVHlCJ}6zkOoinJYolUHu!*(WoxKrxmw=1b&YHkFD)8! zM;5~XMl=~kcaLx%$51-XsJ|ZRi6_Vf{D(Kj(u!%R1@wR#`p!%eut#IkZ5eam1QVDF zeNm0!33OmxQ-rjGle>qhyZSvRfes@dC-*e=DD1-j%<$^~4@~AX+5w^Fr{RWL>EbUCcyC%19 z80kOZqZF0@@NNNxjXGN=X>Rfr=1-1OqLD8_LYcQ)$D0 zV4WKz{1eB#jUTU&+IVkxw9Vyx)#iM-{jY_uPY4CEH31MFZZ~+5I%9#6yIyZ(4^4b7 zd{2DvP>-bt9Zlo!MXFM`^@N?@*lM^n=7fmew%Uyz9numNyV{-J;~}``lz9~V9iX8` z1DJAS$ejyK(rPP!r43N(R`R%ay*Te2|MStOXlu&Na7^P-<-+VzRB!bKslVU1OQf;{WQ`}Nd5KDyDEr#7tB zKtpT2-pRh5N~}mdm+@1$<>dYcykdY94tDg4K3xZc?hfwps&VU*3x3>0ejY84MrKTz zQ{<&^lPi{*BCN1_IJ9e@#jCL4n*C;8Tt?+Z>1o$dPh;zywNm4zZ1UtJ&GccwZJcU+H_f@wLdeXfw(8tbE1{K>*X1 ze|9e`K}`)B-$3R$3=j~{{~fvi8H)b}WB$K`vRX}B{oC8@Q;vD8m+>zOv_w97-C}Uj zptN+8q@q-LOlVX|;3^J}OeiCg+1@1BuKe?*R`;8het}DM`|J7FjbK{KPdR!d6w7gD zO|GN!pO4!|Ja2BdXFKwKz}M{Eij2`urapNFP7&kZ!q)E5`811 z_Xf}teCb0lglZkv5g>#=E`*vPgFJd8W}fRPjC0QX=#7PkG2!}>Ei<<9g7{H%jpH%S zJNstSm;lCYoh_D}h>cSujzZYlE0NZj#!l_S$(^EB6S*%@gGHuW z<5$tex}v$HdO|{DmAY=PLn(L+V+MbIN)>nEdB)ISqMDSL{2W?aqO72SCCq${V`~Ze z#PFWr7?X~=08GVa5;MFqMPt$8e*-l$h* zw=_VR1PeIc$LXTeIf3X3_-JoIXLftZMg?JDcnctMTH0aJ`DvU{k}B1JrU(TEqa_F zPLhu~YI`*APCk%*IhBESX!*CLEKTI9vSD9IXLof$a4mLTe?Vowa0cRAGP!J;D)JC( z@n)MB^41Iari`eok4q+2rg;mKqmb)1b@CJ3gf$t{z;o0q4BPVPz_N!Zk0p~iR_&9f ztG4r5U0Fq~2siVlw3h6YEBh_KpiMbas0wAX_B{@z&V@{(7jze4fqf#OP(qSuE|aca zaMu)GD18I+Lq0`_7yC7Vbd44}0`E=pyfUq3poQ-ajw^kZ+BT=gnh{h>him533v+o7 zuI18YU5ZPG>90kTxI(#aFOh~_37&3NK|h?(K7M8_22UIYl$5*-E7X9K++N?J5X3@O z2ym8Yrt5Zekk;S{f3llyqQi)F-ZAq;PkePNF=?`k(ibbbYq)OsFBkC7^H7nb6&bhDx~F#muc#-a(ymv|)2@4)NQw!cgZ|NLJ@N6o#y!T* zi0kdtK#GC8e7m#SA9pSuiE5bOKs^ox%=l6KBL?8Rl;8R~V>7UCaz+Y_hEOZ^fT}$m{$;GJt9$l$m3ax6_ro{OH@r z8LmGIt2C9tM6fNUD<(Y1Q8w(aN2t@VPrjc;dLp9756VNLt9&>pX!L*6kyU=uui9e7 zrQ^&h7Nuk|fa1WH?@{DNg}C&i2BPX$%)+AMi%-ImT2Q_QnRV)3UbO2JW7T-JYoYnU!(}tii1LAN|D(%7cL@IEI0mCT0!t|kd)1KahVC2K z|9L76JA1F#-=|{!eJcN|r2bI={kK#3M*^rokSGIa zWe@gc$gT&!Q!WYqGHNy3PlhBvcjf&X0o_R>a?DGQ`e|uWa)>YuWk(ibM6r_Xpiaq4 zWtcFh6k&ih==f(%+T$`L1EYJ^CeevsviNKGK3iUF&1QI!EZOR4y2d?z{kh!@hfoR4 zR$n!oTq-{w^eSf-ckrX)rp`@DG4(8%e{AtoKlwoHjNIX8hY>P;3y*y_O8XZ8ien=J zQR{%EX3|XA79>Al$+8(rw$Y~9ydiaH!@*{;*H_Weng(B+tJe^@Hh~lm^J?rL_`0$g z%o51AI)M5AP4)R##rWU8U-|zQ>N#rK?x?C*TS+B3tQmUYjh6X32PBq4xJ`|D)tg%M zLwd8z7?Ds5CNhvE8H^bY$XD*~ke$yZo!3P40jio4f0GcqUohXX>C;+gOt>>PizdRd z?{b{G8+tZA!Aj6GmXFD*thAzMDL!h{90}jI=PdjS093DQi3v@l|5~^hKrwR6 zeUbcTjhPDLUg*ao;c>8JN}wB>MOIE^vN22t5147OVW>!BTDvz4xeP$B({i(Po~_BL z9*#5s@;l~%7S3?WkF0}E8>iN+UQZh{-D}3F##`x$+YG@H0vyyD%vY!zsJHcnGrN|& z;j<&E%0i6kwaMT{tjp$m5^V4*+9;13^DDjgaFvvOe3=j2hWU3(PY)kFXvfx#EJF(V zM!l@%;xJuF3pERftbWw~WnR$A&ok4UQ0dISRjNi-j7>!WdGm0^FUmns_uy2DYX1!< zihag3z-a%BI*WE?er9_UTY_Eui-R>cvS1;=N#Bv{mPKKIv5O9iXS- z3|WAAOhFjGB1il&5F9vj6Vm!t99VnZ6v)$mKW$!I)_=41msTtDQ`CAV`azZw#(aSt z5XK052F(2mTOy|hb~KaAM@(Gg9l3=rqXB79Zp!Q>)*)Hhm(8O3s53@BCx_ltYRV=o ztb3!SE4UlbZadeiDcr2NZnT1}MNd0Au}VRHKQ!`nW(2!sPW5ulYI zosR$tFs@ul-q2)^z}}Y;3$Jj4J#kik5ou3xxf)_JL$5C!E%MDFH5fza9unrHXXw5F zHY#AcZSU73&;sy;y;fM_*p0Txd{DmQVYSyT(8Bu@vSLZAPKlVDd&6%bHj%HaV1{=L z91uK99)#H)!*Q6S`Dv))pyUoDkMa0Sllw7Fvb!iKKjbR3>q-@zp>$lcNLt4(&F9yk z!g!~88ulk{z2xgG-3{{il~#8wah-S$PDsv)h$4v?e@iEW{%JRU21>lL%fw8~(DT#^ zywKIPee|O;<3lWQL$hEWAUeA2)~-xA7yV(I(Pe55DMTFD&6fP6bS3JXHE& ze2nS2pMh>pdB%}#XYcS*N|SMQmQ2J&7WZu72OP zj&wXEJHG2^_XZLJUco>yC|q(0L~1fPN+}|}7%$xcp-i$$kXV=D`~$(T`2Y)+8U2yu zvr%Mzd~RzcUfF#X_+uh&RV1fO9P&C;yFTuW5sb%e_xPYEB%AgtaOJ(ztnLEW_Hao2 zZHV-;f-^2epH zxn#@~NOA z11ZBV6tw5T5>Iz^Jb)0%OIlra;qJl^ufG156Ui{A2$qpZ_{^c1^R`+fbi*WT%;He@ zyieltZ{6ivdgz6i=@iEldc;jVS!5E5$rymBrD?v#K?Mr`?ocG-n&lL`@;sMYaM2m6 z)Tt641KSaR_(MIZi0J-0r(53x)8LPvfBwp-{yFxkKiTU)pdB)FGjC~7AfTS_$=v_Y z*Z#MJ`R|V^X!eb+h*>&0yC}OF{rl;vioX)<^+YRtY&IVpwZx%m(G%kbE0AM%G$dMnxO@9U~x`$qY-b?f@fkQ`9pNJeiFRud6ZB~-h_kWX>mCgONAn%y8FDS z1jJ5f3AGpr111cNW(=njoJxN_XIF;t1dO^e0km*ZO?76yVM(*B>Ix?cT=nC+o2XP$ zo!&hK$H9sd8H07(XoY2&7QG(*iL;qrs4U*82`MFg4P0Dzw%rEFXuGLBslk;D|Cf}sL{Bdj9TpChAGEEN*DvCLV(j_N-e zcLNc98=ZJ>3?UluoPSL2QwygpEHOrNp?KEVT77e1i3zzY%Y9lStpis{$m zm(cz{%HDxH)4xj^O$Qy@?AW%`NjkP|cWgVkW81cE+qP}nZ)X0p&N}nVoOeCvGhF+3 z?b@|#SADRMCTILsR4>rrHy4AU0PJ{|)~M^(@q-e3hLdj7_}OdzCb7?6jvhyQy!)3Gv3ELg)6!VjwA<}NC@GK%{NI0 zJT}T#aRk{>TXHs_T?t5eRw>v2ntXC6^p*jkWo`a)WZ0?8&JFWArnx^e@#->FsW0`H zaG;x(iE*;8ugY6Nhw%)c!hpKUyX3jhGA*i6J6@(fUBPL$z{4dz!^d6OL#hN?41I+g z!KjR5!+yZ+z+Y#U0p;s{fV{jmnQyy>%`Eu5GUWo&fsZL97=D~-b_O#00NQ+zO>XS` z6cn1v6jGixMb@=ItgwK*pbiAms3``uBok32wSnIF!(VPSH!Aca2(cTt_k_R zo!iTIMT0nvu%dfM`Tm^UEy_oqiKOy5hANU5*kqB?bbwBoz>e&)X{#5b+bFeY#FB}p zj#JFe|1ix8(itqE%U8Oe9{8p+lmPB#ITX?HhA~WU^`aMeLagZ?{J#$k1(<*Ga=!-# z(r?kozXS&T@4ut}e53yWT>JmB5K8z*I`ZXC(_u$bUyRSI0_sa;;}c3a_~)8{7*#4- z*hR0l-h`v$GUX!Y8S$OAGx`t7Oh5c~5aXowl-+DBh(YT4|& zz2Q~Iz2(b(#FdLc$(X>h-N-=%K&sS{-j3KfIshl~vZ(yd@zZNg`=RANO&IW5GfVZE zs6mU)V!n_RSxggdO;6lhUb4T6hUvzQ$bXz{bZkC4QCxql0E>+~jH^F@J~OC%bQSnw z!dVcM*I_fSE>Yp7Ty9TQ8VjoGh>2rpcziKFwP#ZBOnF7Eb+fb#57*n=S;keHfwc zH49H*3q*cDponQrD`v$M1l5b=n=zY6HiA!3d-3ZhDZ+LzKN9kDW#xrc^yy*`$5>{c zL~=_5`{q}NdlgOp5;!td)>hv&2umQuUJip0G-qJ0O^3tqXGdqmn}Z9DTz4j33Oh6* zRs?8e!2wbIsGfGP{9#WZD|RF{E86KJLEy$vz9KuntCBzNS(>A~j5a$SlK;1USU4_S zB~S;>^=U+8Kqh5?r+Nbfvr>prvVolf25hJ>p9%wx5ew2uyC4l%vXv}jkoT5T@NOml z^@+(g=Fks#f9@XKR3CWI`oEWac$gIO`*&M%ga!iQ{=d%2|J9ZRjEt@AzT>j~_r7Ge zrikzvS+U<-JIh%phK;}dvq;P%#NIq@*-Ro zG795&jLHtK3kt@gsFnVb^geyY&Q#0!O5NK<5l`92U6zg)2z^ixqqM;dD69k{pn5na zjzCXM7%i#qTM&x#D|7;Cs8qI%RB+HS5}ROsznNr@l{c2b$1$=!oSc;%3db4qHN!gG z%>$rEZM~8pIiTEB<|bT*mBLb{tT1uWu6OFJ)KF7(hj^P2rs5QyMx#q_*|BJuoXwJv zyh%!-X{q#YM`heA8Hj!57>5|U9qR_sVak1r z2ZH_d(s!DNqIuDZc5gkw(w^h@n7~LZ82aCz6|aG^n5bXeTCFdW z7m@2Ej5B%8MSD2HAr*BPh~b^9^;NJ~HXJJX7VeGl(#=!DS?r0mNIH^}d}=~&Ui+B^ z_wm)B4@6oIZ9FP|3#qxxW6-_;>b*pN_iexjXi=h}e`(krgGC?N9fbTnyYPYIO6K}B zFA_P-suUrOEb6b`R1i9SkQ*s2Jb7^Y-tOTodB9(}j@~WUg#QJE`jW#~0+;?p-Oyv- zf|?tPS8>)50*6Qh^}EqVu&_nQ+F^C-IvX6tCg-UDYg3UXsv^pjsXxyJD>pVkh$z=?hWh9Cyd8bJRGUUU{A@XK zEFVF%XrUA0yYJ(VcELR{+rh(`Av6SI^lRD?z)AQ$gLvakWpQF`_zp{aqZKUt@U1H2uD*qV*seS(QQ2Dy-oc-O8X zMKUd~h#|T^-6H}`fk?iJx;2kI2$Jj;QIf6%C{vhRVjqTvaHy7Wq*g(r%|c-3w(n|C zr9N;Rs9JfUDeCWJFL}uP;Y0FDf(Wy};!IZ2zFjeU(d+_6MEJlaX*p=3D!D0b>op*k zuYr23N1W0wly8w74c#W1LpXP|?)nWr(3eXs$E(c&PiERe!JWE^z0mm5cg@7F`_!@X za8nQpF$jOM+JDY~nb?BoW=-xIQ22c3TFS?M{R<~rPg$le_1#FXz85*d|IS}UP|x1z z+ey;M%HGW3JB?4_`{vKeW ztvEN4bJui=CcnsQr$FVybke#RDpaIHY{GaczId-A9x@ zD;Gi-lJ9Iau-2o;`eV1*3ztzN3!P`Jxrc)3ocRRAct^jD5E<^lS-Z2}IFL)oUQ<%h z4?B_#BP>07`M}`7ywGkk}UQpFIOvRZx*v_~StXIsHv% zk|F{D@%%dlD`92rZ1oTF`=>D~IOsVT{euA~R8PKHPL!_>)`|SN9}+Q?LbiX7V;y|` zxRlL>%Ik$H(5Pr(Mxx>JnH-I0{je|Ff^ zz-BM|Nl%;W&QA{{-tTu0O+e~5f#GiJBzZraC7MNqDOlr?|LhqN(b;MvwI7GKiU~0K z{eT373oTRU0c$+Rhw4@XlTr&~#ma@bzsx0Wj}{NwfD$q4FH;&|U+$&78LfwdW8CyW z;OP%PLaqA+xw`)8&GY!c(BaeeC9Brzjgx$h5BNTOB+6D5tkg^CsI*KLgPcM%ya0vp zbV@C>a?WQSn!)u=q#cuPB(|i9nbp{($Sdf>!kHiclcaabX4aUu7DhI!LxJ!}0zu6Q zTOuR4jCzAp4HQB~$lx0-I*OxW?+7`C+)yPz2LhTJcEWDtrjrKPGYcx7JOz5>Fq1BbCwdcc~)V(_dWb^W^Cg+d`E znHou4u_BxEZ#{w1)X2Kp1f&31bB$h<4(gDTg@SKrHdbYIH!LCpjoWx$m6H?^Rn_?n zQtIMb-Te>usVOR~oBNm|$%EuM-Al$LI7T(caHlUC_)EwIwb_}nTuQcJOCTkj73b`fRMv9KQcH|un^M#jXkC}A*2{;)>XL4t%9j;TE~jj=;kQxkt|4?2+jG$ zO>MA4Ihwb3fs%0QJ?(xri>|+HFKQwe~VKVDLRp+kcn%p&_N|cAcOg@pMI36hxJ}`pdX&g37 z;cjX3*$bO0ZP)WGjS+*#9BPg-k|%%ld(u(z6#Rs)CdDq3v`;~(3yzuCIThvMSR?)N8k)5*zG&`Z5~4mo5!kDs8X%#wWG=BAOu>f;BBx)i={ZF2%pg&8u9OHu$RwHWi(Zrnb_F!S4}H4Pemup{B?g&x zU#uE<^xzLw!p;7LfV$qJaB~})?F?0goeb3_q^thbL^rZUwm(m}&9u{(G_k#^JTnZ# z?ls#Ol&@v+(`?BLI#?e_JDXMXZ{(A&w5)*9@rU$xbIzoJK{+Kq$9~gGf?d^9H95ge z9~bmk_TQ;pQR=n`mb-!up;6q>rJg5h&~DXGOL10ZCpZElV9+NXAe{ z(U{+>WGl-7n9_cB;esbv`zQd5PGDmtwrS6_?5O|j?f&4!=Swn)P&{DTRm#Q z?lZCaTsQRukADw>9hvymR@=x9j+`A^;gGe7opW<)l3(+nJ@lsz+RXHLf8DN7;}xZk z?qsC(lwIfrLNr`%cX`j&a39Sp*W&E5ABI{ZAa5xsdUx~eii8JeRZF~w%iTbC#CrAF z-f(##d2g%O_TH()d(?*AHm2=rhVJdR;EgIyP9gikuT_JX+bTqZK_f(F?2|1`kjc^R zBzDQ!BZWG%cOfa7HvQaL{Ub@Sf-hnaA$2DxLI5WNxlEM_Y{{$4dSJMYh7u9pnQdxV z4jn2yc%eOWUGmF0IvlC|>3K7RbP86le>*$oQf1o9Hu$U5W?FiyW4x15Ke~2{<~fNTN9&{nZ5ltn)|0&e(%8lU!5}Jn=P4>{Wc_V#@<*& z#iR_5lKis*QVSbHPz*U4gh7_7OW&h{zBrzGiDu1}dlO-OKldzv6xfgM1;iJBv)(xV zL*nOH>}C4e_pM>gMOIgr7fA9zY$T{1XY4SU7$v!*x(F28!b*5-sBQdSve9%p&6M3A zoF)u_&hxDVt(HQi+d30wc#%MI?O*#P7A-(aDiQVoVBc|#+G2bKX3W9;9o8 zD4HbHZV4&TIV&gj0z6v7AXq7b^MENIMn!!BR-tnjn>8c7k|S+hdv8|W%?0CbQ$7B2 z*nZ5BW(Fd9tQJwZVVWzfGE-5!b%f6Gtb7t<-@dIT#=TMz3ERX_;%e*+5i3(E=Fe|ao}{&(4(W{aQ4Aoc)ELdd z5xg&)DFQ19QdauMEM#(&`Aef|XP5yeP7=4gf8P)3_V6z`))+>cj3Zt1W8V+5k z6@?Vs07*I%!{dvD{3k3PvAAMT~6`Iim@M4XaO_%YOCvyx_aZ#OE zEoQCTV=MOnIy3QCDFvy%ko~6YBp3`2U{rdbr*BHVsIz1!_!-at!VxNhO7NC`mw*3v z`Ttu;@xSWcS?XvTO7%Eu&JIN?8S!yGelAjipZZjjL?kL>E`1=KPegVn$cd#Q3 zmrT=BIxi`@g_jH)Xa+_?g2hpyNK%m(2OB8!%k?+{0(O|w)+-aJ*9?afapdUc!Kzrs z{bs76WLj({R!@J8BMHvCo3*s0;2pzhzGX)r8;v!#bHTvh^<3+|+&~E$E|kdCik&Q* zvXm9N43@#(!o=hFvr%fQ&OT-!rqBw$jx?HZJdVPlcdD=K;SDr6uCWgM^>3>bYYyzD zw(m$e)>4rAZ2TKb((Vb1@C$)B zlGwcqUCU-rWbV8uqUIsl`VCcnOj-itFqI_2Vd=!Iq?jNi9x#_YHyx#bWu>p$(+<#3 zm8~w;gB*jg_f08pzm}{qhFqd*D)ma%t4`7=-7rq(#5?lpDE3t^qTn!nJd{~h0E~E- zRQR>Q81&d@rddwej@!YvrbA+RoMKfi;I-d?R$U8^y^k3xwU)Hbm+Y+5OD;`JOia_@ z@eFpvBey;1Twd9l*KHO!*;QK5)5hjZ6$t;DMfiE(0a6m5?s6M|m_vXC)Q4Fs9sn_y zI!or%?trl8Gt;p&}Jf;`yVHP@rsXhgAkueW}cmxLXHXddup{SVk z>^B@F*hxOnbBoJ8BbZ4}yNfh{NlUbMcb;7pL3x^mNLtFPzQXori=YGCNI{)ZAZ2Ki zs3qvR(7N>3nl%-R(nxn9g25ba>ww@!Zk2n&Ba}d16bhv_#ER1_5xYp4v>EZSD=SiN zawHYv%hwEpP%wK16R};MR@m~tu!hMb+v9EDkD&DX5wQI`eh`K1)O`&W>qHzi z!b-DJ&}vPMc~072@*LfJeLTEC`v}F87}68vWOcpLQ|U|l0V(wYixZ*=QHzP%b48F5 zDzkei^(!En6E0%9u}ZGpvth=98Ab7vbAkWtt0*l8ho~bKg&k)N)D{X)Sw;9K%Rymb9ZkXRbICW~F^rHlD@gHfrM)$z@z z$hD#^b4Oa|U>c*}O;;{gCD0tASCj@XM=^K~@*b&A(W9HhBW7}y*>zs`L6&b(Numk+ z?}W2dTTY-k=m`2Mn)4HUL~E6!TYM-44baeHe*R4+@g^O;S2E_999y!?b&i{oCw2p8XKj8~?@*s%WZ!JnBS*(vHBdP{u*jZ;&mPhgW- z$TymUXpLsqmETA3RIEm7PvM~#n2jc{hcz=P?u0)H3}EOmNcTzyZTDabzVJS};Lw~R z^_n%#OhfmE{M47|-{~Pe!$80aEMfivs=~;(cxH+gPUI*ZYK)Fs^CUuPfB%5wwKIf`Er>NFR$wv_^&lqkC2)JPA$tSp%^o25 zAg&XPxP;|y!~aPnY+-Z{-RB5sI)^EdId1W3Ryen*fIbqnZ*#ViWDj((OR4xJM)(;? z@Cf4i$TZxF!ziNG;)MR>mr=gWYsSqO1fHC|%#CXi%S_NF)#i?IVU?g9jGmIR0)3Bq z;tln(pGsuhYpC|QPZ-M*8&b?$?(Qip*nJ?akUU7FF0*UvGnI!R3f3ehEjPhPEH4?iI+hc$O*6CpeI~ z4Sg%6ZtDeiGX3M@Xb0VgXkGxN8nJgs*k=MrN#I7+%!m&e>Y)R!$GXr{Ox1#dMkdI= zlKCh%&BnMT;qlKbqHxO{`^lO_0%GE1Wrg?yydI<3s6he$-Lq$K9S~S3G^v4nX^Z) zB1xZCP}vgY{yApKcg{ysSWd~`b){kFXX{Ue7MRxdIp*Pn%tWiA;G zK}!DfOQSN$&ZWcr5-u-l7x|fv7&wHK*XJt#+uRJnB2FM~@^XCA<8EU7^5gaHgUsjK zVOWSyGNZpfk~vg>rhqFct7@kb;0^O2Xsel9!;mh_$I zaKvjBu*O_)8H>OOS4ydd6g-9Aa_$Ws${Ws6Fz0|USEkulnyRswYM|urnEWUey-5v< zK|YioRQPd{ip*!92N>e3y5>A+Nv3n4toNold<;@)Cpa-}o{A3jKdb?O!_ZABIy-wA ztzaL_l_MAt9Aem+gcuy}HD3IYtK{aB*hzTjXq&0A@uXRXv^;8|0?@Am=!pbiG=C5N zM)McoW~TRnVW3NZq1KJj+xK2C;;K|}6aa~;Hr(bM#K7Rt=}86*!4%lv7!SYq>1?b! zoj=E)44db=!=F?h3B5g#AL`+B*zeH*a^T`<+KZ^BuwjR)kT#^@EDMz<=4WrL{?JQL z(Midu5k`G6nx|MAl2Y&qGSM%%J)+Yw(FWm|z4fu4I z{{3wjNT2C$ql;!i*H5F{3gKU*q?bZrK0;+SlBwYIPElp%gqUQ} zu~PZr#qYvYE(y1#z$@vrcmgY2xRG0o>lUpzY=8Rxlo4QAjRJzT;NnCL<(mUbSdA4= ztVE89jFFMl`L#!Zg%3PXupV$V{iK<4bVwi2|NAg#!f#s}|6Tho-?jh$0}cQ0{CR|dmG3a^sq@LvxXZ)+3$dF}+2P(mIEWS<*7dvo6~{*oVgRl! zQj7D|**X2unoU|<->1K~fm%Nsb}uww1XK5 zPTkQf9B`IX6+xXBtW=vbHP=GNFEGLjjx=4n!T8k>P0Dxgg)8?1odzkeL#&YQ#Ot0b z=PB19V^dl>CF9vFxxuNE`{qHrf083@(u~2?E+QAb|ND4Ak^;V`^p(&%y!)wtA0#DI~1sjPy=Gl=Jk_LKV+s!Y^j?t@%~H!tX2)H zm{hZ!i~RL`v`e690}D)}3FD}V(vmxXyhY%K5Guq{_Mv9?v2lT{bOWg4Zu^7y1ar8n zmAHd)JADf~14}K&Kd>r_R}_x(PBD?%GkD@IDUklYfy|?y1BVdi#9312{)remsr!-H zjW0tu#v*ygyWbLt^s5_5MkpYWOUgiCwk>cCafD`_APTvKBz%WJjzlS-G2A*dS)qkQzz504s~eJE&!(*U_>0mr$HykbwGNoNWwCEjL=c7M*D!Nb`PH zx2NPxryn>XZ%|N7#-LQKLHw1-kG_2=QJ2=JLW=C*nydd_?z&Q5N}%86-u%7SV*Gb- z@Bf(i5)`(qXJx-{k|yJdb?lP{@*FHb*?$CWe>MafB>S6?GqJ~&cUG(*a1pK4j zcf{!2#D*VPQ_jByclkm!s~C_7tTThdil^s=WdwIgp0IA$=lH>9hCTx z5Xr)>@*R|x(DjaQ$DHV74NS`Whn+KWt~fSy84>OBxriMf6kUU4Q-kS1l88`oJ;U37 zBQ0WgFx`l;cSai&{i2YGMjA#*3na}+e^znG8aHDsy4bZf z{#LURLOT3~vp8(Iz0R{4 z(_8XLA)?)amfcWVTsCQ-sSBOwSm)13fLBY`sl!Db%2|ifT=q zA}^pepW;deI;)PQ&|m^3N#3nC$*tDKC&*TfWst8|sxfW&I?b{?nN`JNk9Ca(mhRwR z;e*YDD(uF0O__g-j`;qano_bd|GzAsI+Vubzr}$(&aq;>^uHkxZUTeJ#UKKb;6ZDm zXJ;v)Dg@N3+lUox9T)|rNJr_O>1gvqMG~O-x)ZQ{39k$k* zrcOGGtVyrDyF9^lp_*9wqZg(DHLU6pbt5$?+x}t^@`ZWLSOY9S8qUS0f_DMG--u2U zVVx5|fL}q@Sl3A;632wqbUjvV!&-8wpc7-pG>olAC=&9uR9P+aLa{6Tryv9JHBdyU z`QqpdCu5x$noe5^wes^G-+w6U9@E!NDHQLKi5hO!OIh=Gi{cttNKdQZov`>`$0}qW zwz3-)$gk3`583rGJ_}20tDDcVxc&m|+f<1AbLy?n*OZa;*e5mRaNf1g%?~}~d-9qg z)YnEg7G_l=&u9@fFIBKaalRbC<3=@@*feY>lRsNADQ15TvdRTJZ<)eCYVPqzdL=Ef zN5(>Vd%-(d`|e!KyLWUEG);_E!J-fhAOl=zUcrgVX1&hj`Zz+wvF9Oz%X4gGuONcH z%h?(;os*+5gzz&rd5$4ULvA`P^W&(9fPMjG4QPG?KhaXi@O6O|U0j#gaaIq8)g2TV zw^p{f?V!a@N*#6eiN&o9wm34rAKw#f?N|a+zzc!gN;w?_aaFF$hD3`u9UipKy2=a?eobQF_M*REf$ zj;+{$jx7^GXy!mmwnHMf3B}G*11Dl+ur+U$HV>=|*rWme??d4H)D^+~34-e<&T4fK z9ektGZMEA`+wEVx>}pcQ8=?b3U&4M_&cEw^b7&G~t`IahA*>38X=Dd9PK+d+v5AchxFfgIsaho z3^g-d&4HLt@zfMHx9?onm0BKMiye@&M25!d0|j0nObOP+ni%+TRkv7Sys6+6#71_3 z=3c}|gh*XvU|-!JP`?&KXx|m7=3b=XOQhwATD=v29v@f&3!tGPuaC{Nnek)Hkat;U z8D}L&CC7!O1(_;b_eTUDwOd6z&YPOQpDHX}OEqX&rqBLxbi6Y+6raWRuS~FCMLRMt z&#=5pIeXB!uFvv)dfz7vM;+QgV~i`G1D= z-T1{F=Svc>DCY7thwMnMEmQWBpxlHg7sL~EN*8FEl-J$-QY%K%J<1cYy3$KV zG+EM%8p|KXJPMwGyQmer(9LR9MVP?GkZ=w}PhCJq%Z)LsM&!Gw6`W|6YLt|VXVknn zG+d8xv`&o*XpcrIyO?E>GlQ59W6fo)hgdm&!us+gk&~Z(xzd@ocd|b&VXN{1iqTsr*tppm%|xZev}kgETo?Ip)PrPEKQ`fJY27Z?+iQ zPb+`K9I8RYFXR$~Ml+_RwfhqjPI$G<^2eQukio^mMUAfca=8^`P$}-3av))0#reBX zJO?KRoQN}PfKy6EWE<${E5oA4psTIXI5R3P!`afUEO#@F#cW6?SdJ)pjcBxn{HXms zby#DnxcBA!a)&`0rbZD2SYTN$P0#hKE_J>aS6t>Fk>J=OkHFT(x{~rHi3m`WL<=kn zYqLhsunHC_IFkJ)nD=}RTK!-#DyN3zk?9q}WQ|y1rKvmlPWbjHi7UlXup~E2|PJyPAGVueL7){V%z~!0G zXAH|iVbtT<`S2``Tz}5WNHpQkL-$|7{gJQRQ z{~K-@lS>`6>%9heUPf-y_RL%GwF=+XQ~OK*X5E^AVS9Hz$Yi?j*y$}A5lRJRSrKl( z3QcA!z)W=;sR?}0Mz~&?X z!oKp_GaPNka5j@l=_W8i_Ofa*C=4c}Wn{Tg&f#Kv>KXE-R$KfXiUCcU6VXc% z=8i?pTr4YAqN+|9NHN6(T6PSGByZO+A&`CaMYXfh0S?fVLF)`1*NWI$0?QTU>kd1; zGzWn5_-2B({Gn)x14cpGBq|78lCZr3xPjhMM!`-370O&|EV~3vDVO@igfR9m|9LnF``CmprMnO!UW=7QAFV7bZS z&97u9G63r&&SVh|)l9V;7LLGCY8;X~D^VDNon%jj$@1u7VD2c4OvIF-u>sc%Ihq#3{;M1c1{1p*hfy2MCQDBv0zVR>fl{I|lfOf;-g+=$^M zq0Rs#+yN#^6GhBtw92LZA^WH9cMTdqHT|aKv9`5>skD<(_o8oU-&XLEN{BSkLfhlzuyX9QH{N}qaK6~?EU{Kz zFf*F$WS+nvgybofAOzsSJB2OZAEG_m7vlWn+^D;_jaN7gg(HGtYw~px zw}w`idAI|sf^=i2^*GKT7v~wW-*+2JZJYOB6^uJwuw86RE7aIFD9F(*S)1|L=(x*R zBloIwb9(ht1|YF%8f9femH5?zGAQAwWo zyqo4TV2R=B`U<5m8wAeMHEHpWnOW5wp)I$xr(kkl)R;Oi0isun=y}c-l7LZ7m;lm$ z$q4Iy6Sc&$7dUfcx*n3=`*`*UR zN1JtLOUYS-=7UaFQks;9^B@e^CN+Pz{Jd$gh_F`j>;ZkK-Md1}-@#73aDFjIwBy*d zTlwKK`nqGu3$(>F?Ap8A?q4y9mka`bxGNnAlZNNKWA&(V)8YwF5nmp7j%ul`_QG%4 zaeXBNd7~ytMg3#Xf>6W<>tYbEa%-$6=;P^Sh>aUHZ+e~0RG)Xi3%`rEs8MS8uYqwNdw4SWVkOjZaf` zG5VfUUiPoOG}N6 z<{qp@h!mly6=>7I?*}czyF3Y!CUIt=0}iD^XE&VrDA?Dp@(yuX{qsEJgb&Q}SNvXl zg?HrA?!MH-r4JN!Af3G9!#Qn(6l%OCA`)Ef2g8*M)Z!C4?WMK9NKh2jRTsnTgfut9 zpcZ7xAHd%`iq|80efZ31m3pN9wwBIl#Hqv=X)1r?($L>(#BR+)^)pSgbo+7#q<^S1nr$1&0=q$@M&POX?y?3L&3X z!%^Atu025LgEZ~|-)Cd0=o8K9A{$sT;SHj3M?l{!Er;st5w=T=K2^hJ<$(>&P!j2m zy3~(Qm?r5vh*EGKNLnP31{fhbiIU~c2GX_wqmM}ik7)NF$bEYKH^bK?MD+uJ24Qa=6~Fg-o!gSX*ZYoo{fzTLs$371<;7oLD|PiS3s zz;aIW1HVCV2r*#r`V-0hw_!s4!G4R|L@`u_;)KA?o(p8@$&bkWXV*taO%NC3k? zok=*KA5vswZe|5QOQd*4kD7Db^c|__5C;&|S5MvKdkPtu)vo}DGqDpc097%52V*z( zXp%Esq4?Rzj53SE6hKu;Xc!&LMZPPIj;O-Gnpq&!&u5db7Xi z64ox137#@4w5it68EPn<8RO48KG_2>?+Aa}Qo7fR%&wXJNf2J;Kwm6Opddsyx$gY# zU+b%y*{cBju|sw!wOcY_sMFWX9(C02d(;_YQh1*sH9?j$%`tKJyd(j0PtK#D+KLHI zL;b*n{CZ7IBb}MUGdG3l2vFGJn3TOYJD$Hz2OOy*%!5a{!!0mvok+e+N zaP?Ndm;SO(8-v%yvu#Rr;qFSgZrKJxV^uEnX@L(r4)dZeyh@yRqoi@3M|#Hz`hHN6 zA|8#&oFv8+1F8t(#j1%Ywdn%N2uREt;@bFAF}2zeI2KE&uZr$?-SIwKu<5ThXn_}f z`@RRcJ!3;pKi>mQe)VU5;c)zA@b#dd(J?}$sg0K5L^fIm8%TV4|>Q?qdfMwAh4AM8l8J|tiSF32B4q`!TYj_z!4Lowq99lipY?vlC zJssf0Vy+@In|fg`2sUl$wDGr$XY+4g*%PhDjM^G!Z{H44gwY-ymOqXka)G3ulfWdY ztNvx4oW*}=5^&NGhiS)Vzwb4;K`^*tjj8h$esujKb7&}?V_cU5kQElGgCL<358O^% zcT-EwP>hqb1%_8C_5R4e#7RH zp@tA$bVGG}q@TDR#-_^YT6}Zo5~p_5P%C_pRxwhgkor!;FtNFF#cncoEHm=#?xtY0 z1dHK{(;)5CQJ`0upxdRV?(5PH{JISW%d+@v8FmbTh9n5TXGnM`Cs}{(AbDxaIg&O2 zg<~{fKtj#r91u9PujPqhkFt7tid?IZ={dML<$3sh;A*Hw=VP++12;lVguAyio!na#kaYeX{|8h3_;g*K=UEf zU*{ZR($$Bw*(h;CSO4{alBraU^)52&nxLKUxg=1N5MCBUJ+3a^`9#f?7=4#`&oz?k zoz-#s4C)f8Uk@S*VF!Uc>X}9M`_*gkn0&GI2R*j zUlHUy5b;rLro3?bBLIt%dRd~2lT@kjcfY~OL5ZmTl)ExZyt!)^K#1p>U~rdclk``e z>=zHu6Qp^z%nX2U*RE14f{$U0*Cf)LfBz-c)t%iD%3wxsgHpRPvieqZgEC0IX_Vkd zxh27*KXpXxYD=^PP&EtX{NlX zC%v9)Wz6De((qH}Jqg-g`mwJ!IZ^L?eE2PE9@#9U0T>jD%e^K8-Phz7cZ-bP zU%h91CvGtNYmE{gk=tex+96fK^!I7P7YI3Ma}h)ty%NEN zn}d&kVV1DM4tPht`B!poikUOE396Uy+VE|E*eQuq zoT8M0M&bcREYOX7Q)F5+d!xec;2;H!WO+!r;v#uo402OEt*q%vj)mC@8wg}HO02G( zYG=<5*Vgl3R(5)N@{y+rvBY9CgUHeN`qQLm*3;$@Ez|2z2j3@V_m6j4Kc{5MTf}GG zMS_qp%5n(5$y|Ke#!!7w$4KKAJmhA@sJLcoS}Mv+l^X$2DS9H)ezLP0LfVpNMIPwL2U@Y%%7Q7jPXmGSPlRwa7*y~EkqObIDtyFm)q z-D~m~?At^+db`FvO2uEi2FuK@`RaSN*`T%G!}yA5f-hG1SYtty+Q}}`O^In~cgi>l z=zXVDDNVH?QHtgup3*d46+OEicA^)pIn2`}B}8}{g`msSbzzvq5zHCIjU>OrtmbrG zU26iOxr*A6%_LC(|3nH@ef$16q%glnTl}ob+(w=A9Uk48Pe(F^%ktv(oHC2Ve4|TE zc6J5le1ZqXdLP~+(UY@`Y?r~{B6_Alh8Q{OmhufQSf94*GFtAi(lV<=!6wqxL;jck zOnpR+=HK3Nh}Vv}%LXPzn;0b#^5Afk3y&G)X}NEkE`~TM%tU-P1@^=msCxOyP!IRO zBegW5wZ@10CM!9*_|kF~ZSxrk>r^zyCL|dy9$~*`OX?>1)fL1l(|lW|G!``CEq!N$ zMM)W~G2zDb6wA#)D5OmIMu_&UH_5B%DJ#NKl#R!?QVz>y5jLrK(-JpI6LIGVyD%W9 zg+7;cE40;Rcv9 zkCrUgZ-H}IaC=aY8~7*9+Ny?O=Ep;yso*#-SesEGSa3T&e&DQ`k!p#Zgb<6@KRjgn zG+Z?LoNstww}#+R`Y(?d>>GG^ncorkoKX@REYSTD zQTYHMwNiE~9MM(>u%!3KVR=O=by_thqeFR&Bm;D|lW@>^unOrb^k9yd-=S2LH0S7} z>ae^bwruKEB*7m=)u$5MIo(`)Y+RR5o>9(DDDV623UMVck1##|b`7H%yjK9unoDGkVIKrG*dvN;2S3P_9>ckR6c?7n{s5v!i;dE&<_aDaPA_ zi>Z&SHW^bWYJr-2sb7{WC|0k-a}7>k3)*YgZora(7dVnK7b6?Y7U|>t*u=-aLgC3` zvnz>+QQ_%r^ePEJA5X6^`Ey@^#{dDW(QZr*A_L9Y+QI4?xFXAQ-JDe?&YmeAVN{2b zK0DO+&S-fQWDg`ab0$mQodAEemrA3p{cHbqx{yVqz5Ns6)Rixse^k(i5spvs@22QF zAhsD~>)rC%n(#M+D1!s?DFCBTRfNF~`N7kC8by+1samiHH9dbid%Masz0;p`l^GuF z)taCc0FD9!#^qP3B`G>vZA2db%ma*@6WNWW{*kPq^|f^R%Ee|F-FM69H)u|#Qt{qt zoi{%@b&~<}!vBf99Ef=ih~RNSh2LT6zvdLf+KCi=hu6#d5v7kpppM&Z;F3;`{0FxW z@#nY=LnIjx1?~XD?48~y)>Y&odjWF%6G64~A_3<{rx6>R zqF2ozPyJzzmcF+3AQwJQ@C?KEo|5k3xP%;^ZN*zpQBm5ho(*e)*zn8NzzzG6V?5V0 z2<7tkys|TInay6or7^K(y0ZdwJz|6$blXL}SX7s2es~5{gYwS3d>6k|3V9vz-#G3! zh@|-B?^JP~seJrS$&XAfp`RknZ!pFw@e!a9WgKijDz3K#6@`ifTCWHTa}Tr}n!~;0 zh0~X4_sEKGZZ^}8+X9!T7NazNv{%@nJgpJ8M;Oa zaYo_2Qbk6_j7W15!`+XKC!`+_)IGZ>r6X=buKUkQ*5wXs5}A2D@eYvF0{q(=wm znxEYB{>rdO75{|gy2>`^UB!(y+9acVVRieAMG@Lhf)g>yr+Ccgf8oy1qUO@L$n8@A z;nKV>muW=<*rD@Su=A?nhxTpx>?1>jYOk(ytb|TNwq8q1{;WERaWZi0ov0xFjiIm} z)PkKhn`#2CSuR?p?4)9Vk#`#oL)#q8!B*j3s+x*6kQ~2Pog{K^{k(=xfv{IP9MecW zCB_bMVE;HQS12k5L;tHHjhJ8m%07IN<1N(vQCG+8IilmMo{g$Y5nrPhSx`OH03*55 z;^!ZP!KR|h3~K&8O?uAqKie(}FOYVMt}S-M;FF6%#pX@C<8P!jbk&G&a^_Oj+^2Ys z*1tnnx4eOpd*hgE$xD+(iTw1TaGNs=4*;Pf#P`fd%_%)Jk|eeooma)pR9ka)Ek(PX zq2N$R8sio=D*TQ0BaO+M*8wF-0cR8Bq6vZjr?NAFhjQ!V_)x?Yxmhd9T8#bPWJ^p2 zVbs{=P2C~;GV>Zlkw%u3?OM9&TE|2xMT@t3uSiNEt`MOO*Q>52Wh>pfXJR}YW6XQ{ zJfCN%^ZlJU=RD7Ip3^zMKT-4Q8#0faYOd#r>yK58)sH5XCS>Yj%p1^_p%gSNX4Iai z%;dio52O@`qrWD0>K#6CJvdGFcB%`pA47@W5qIzGe`HRY=O5CK4bZvl6IkJj{#%r? z|A5O4Uo8)Ng;t9f!sRAIsl1a8=TST_Vn(m0i`>XCa0r`>YP-LwxB%^wu8;8+GdQv( zG^usXB?ocI0_)y0MR`T!?Us5ehia8>M~+$sXlUCRovE--QR@;Ys?Ozq9P(Q7ZQ43> zpIo}_{z39UhS{5f8wKSDu+TKfi+#n{O-~4Uk zh*EmSxYYrfwOxCYV}}!zL%2uIc%Oe$XRV@rFeWeka?;Z(XI{}`X?HJGyIgFm@ZX;w zsc2~^A%MTLdqhpoV!jr)}36>dv>Px$jJImpFCzVcs)1b7l%&=qcE;^ zEoSbtk#6sYkpC=iQX(3 z5EUP%LDh0p49U2=$~DIZhi;dDRKwLN8`|PiC-Echa#PXZ|6)S}wWEA@3f!rX>G_!A zphhlmxu@3JVRr3xOWD}*UYv04{*WHt*vT;0@pVLmuu52Mb_Vg9Wg9EUuA2 zl8?Jv5GSU+*{PO$tBpirns`>?!VL-cX@gZO&q)OL%2_8U)8r*4jrGrH`p2zV!T-&| zaf{j)uCI!{A{R9~aJ?$SZ?kk?jfE7FM%1sOCd&S0B(^ckufHtAOetsuspYrqyZ)x8Z8=dG=GG1lcFtKmoxl{>m zAakHGc|f5ZKh>>}F8qu)Y29d2Op+uf?qK|dKPwE!pPkfGl#Sa#?TmJfv}jA5;1`#= zQqplM=!3^!2QZeCx7wu8uWl9!IN85^zrmqGDxsj;TVs=EU)ubiDaD<*@ss- zm%Y-l)9@TN+_0W7Ml5XnEz>_ep>fFIL{5V-n#cCKFhy#0p;!@D!D-=e{(8;*$#2G- z-~F3cHNv>%;D819xg3-F_yHg8bD1W}{1-kQ-da2kMRP?r=@>BD^b5H6=`Lf3y6VPn$`%)-GW}O^kSon7EBP;q9?=n_7O67v9pc>!pQb z)auPuaqG5v3l(E)_GSI_vFY2BtlPgw{(hIMip%d;>9vWnej@q%qMva4iRPI|N7n7w z(!_tL^K*((d428fyiU(eFYzyaICWGnFx_T^a$3(A4p<5kwVtGjOSNa=ey z3;wiIDZDmghb8BsMcSVyT9^W#{YkoGJ9As)0ccff5 zB`U1^TKO@jql!utGX7_6ceT=$mJTWcQ+7_Fk7=jIE7Lu2Ja%~~6K=X$o@5Q7)=`Ao z%Vptz#p~F$l82kO>0*a`LQ8HomkN}$Q0{w8GzfUMX3_$LbiUMT6?eJhshLtmT2m`2 zrK@zuUt8C6$2Zb?u5HM~2xm~H)s1rOJ^3v#{cdG~?xM<+6Lrd(chPMthvmtIcgJoV z-(H!YsUD=t^F)QFU+e|WYBXo`#ht!`&flPI?tga}(nLX13WI~;V?XO(57wx&_pbkw zBgcA$g+wx2w|Xvakrlw=n~x7nWeO7*SwR2(p1`8M*~Ae34SZ&}#$zt|Z%!C%XpOXbpLFv5`sjlu|+#!Pgo9FXG>J~QZn(O%YH zBWQs46dZC)E;!SviJp zefD-koJ?SaKCq_$3t)wALZM_9CQK zGw9iXX^iWLHTQFmME^y==>muB0FYBWAg>aJ#z};63aHSV~ z^&BI1Xx6m%m3k8-P|$7QUIaSpT%uDW?OD?BB+n%~l7+?9t%+Q~hX?=}`?8pcPE~ed z2_t~uEm#W0-QN{N#+ApD+=zZSaBm3ob`3@h+u^Gh4ttNN2s$sX!nzuwp?JOsGoHwj z2@l5>ME8YD3`fUA=$RfY>9hSG4D8@onJ^lTK8T>xz1g7`#v+8NaNr$;IubZHjA0js z2L>_#pi_KLjIjbU(W!eWi-1dyWY}RDad&1C;~9SzVCP+CjBSB%W;hBDGdrDHyErp5 z5X#cSZWs?oRzdJKA&bh!#B=h>1`ELv5fGsjM;8grEB_Ml5nw!Q?T_Fy!`b1Xw-Oi& zJK7`IPZ8{}^QU`YChTvFFb$*GF~83#Ejd(!t%MOOCWZs*(#FDY@nJtyM5ys3r$RH; zGwY5D3&8G^h`_zm90;)SqJ))TM><4FJcR=#j{NChP1sZn(R`H3fhIePF<1&VWkIAq zW^y3K#-asQg8eTLr4LygD9v;SEK4^GSPFI-K%^#fIhF$V7sl;-&O{IvfwyiWBC85G z7MZzT=Na3;D)1g*L}lf9j#XxMO|l*@z#B0U0n~;6Q((CogEzq;QX^ml3_auK-QH(! zYRlFYydetV8<%jvXTLoPZWwqE2_hCzy1W?cwt!a;Ak6maMa=Kjv3M;3Tu%5uArNL? z-SSL!&nS5679sOBE+%t6kqdtVcsdc$>26x21CM6sb)#h-?QyJ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index a4b4429..0000000 --- a/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,5 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.3-bin.zip -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew deleted file mode 100755 index 2fe81a7..0000000 --- a/gradlew +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env sh - -# -# Copyright 2015 the original author or authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -############################################################################## -## -## Gradle start up script for UN*X -## -############################################################################## - -# Attempt to set APP_HOME -# Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi -done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null - -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" - -warn () { - echo "$*" -} - -die () { - echo - echo "$*" - echo - exit 1 -} - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" - else - JAVACMD="$JAVA_HOME/bin/java" - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." -fi - -# Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi - # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" - fi - i=`expr $i + 1` - done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac -fi - -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` - -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" - -exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat deleted file mode 100644 index 9109989..0000000 --- a/gradlew.bat +++ /dev/null @@ -1,103 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto init - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/project/.sbtopts b/project/.sbtopts new file mode 100644 index 0000000..5a87016 --- /dev/null +++ b/project/.sbtopts @@ -0,0 +1,4 @@ +-J-Xmx4G +-J-Xms1024M +-J-Xss2M +-J-XX:MaxMetaspaceSize=2G \ No newline at end of file diff --git a/project/Dependencies.scala b/project/Dependencies.scala new file mode 100644 index 0000000..d93690d --- /dev/null +++ b/project/Dependencies.scala @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2017-2020 Lenses.io Ltd + */ + +import sbt._ + +trait Dependencies { + + object Versions { + + val scalaLoggingVersion = "3.9.4" + val kafkaVersion = "2.8.0" + val vaultVersion = "5.1.0" + val azureKeyVaultVersion = "4.1.1" + val azureIdentityVersion = "1.0.5" + val awsSecretsVersion = "1.12.29" + + //test + val scalaTestVersion = "3.1.1" + val mockitoVersion = "1.13.0" + val byteBuddyVersion = "1.11.9" + val slf4jVersion = "1.7.26" + val commonsIOVersion = "1.3.2" + val jettyVersion = "9.4.19.v20190610" + val testContainersVersion = "1.12.3" + val flexmarkVersion = "0.35.10" + + val scalaCollectionCompatVersion = "2.5.0" + + } + + object Dependencies { + + import Versions._ + + val `scala-logging` = "com.typesafe.scala-logging" %% "scala-logging" % scalaLoggingVersion + val `kafka-connect-api` = "org.apache.kafka" % "connect-api" % kafkaVersion + val `vault-java-driver` = "com.bettercloud" % "vault-java-driver" % vaultVersion + val `azure-key-vault` = "com.azure" % "azure-security-keyvault-secrets" % azureKeyVaultVersion + val `azure-identity` = "com.azure" % "azure-identity" % azureIdentityVersion + val `aws-secrets-manager` = "com.amazonaws" % "aws-java-sdk-secretsmanager" % awsSecretsVersion + + val `mockito` = "org.mockito" %% "mockito-scala" % mockitoVersion + val `scalatest` = "org.scalatest" %% "scalatest" % scalaTestVersion + val `jetty` = "org.eclipse.jetty" % "jetty-server" % jettyVersion + val `commons-io` = "org.apache.commons" % "commons-io" % commonsIOVersion + val `flexmark` = "com.vladsch.flexmark" % "flexmark-all" % flexmarkVersion + val `slf4j-api` = "org.slf4j" % "slf4j-api" % slf4jVersion + val `slf4j-simple` = "org.slf4j" % "slf4j-simple" % slf4jVersion + + val `byteBuddy` = "net.bytebuddy" % "byte-buddy" % byteBuddyVersion + val `scalaCollectionCompat` = "org.scala-lang.modules" %% "scala-collection-compat" % scalaCollectionCompatVersion + + } + + import Dependencies._ + val secretProviderDeps = Seq( + `scala-logging`, + `kafka-connect-api` % Provided, + `vault-java-driver`, + `azure-key-vault`, + `azure-identity` exclude("javax.activation", "activation"), + `aws-secrets-manager`, + `scalaCollectionCompat`, + `mockito` % Test, + `byteBuddy` % Test, + `scalatest` % Test, + `jetty` % Test, + `commons-io` % Test, + `flexmark` % Test, + `slf4j-api` % Test, + `slf4j-simple` % Test, + ) + +} diff --git a/project/Settings.scala b/project/Settings.scala new file mode 100644 index 0000000..5723ecf --- /dev/null +++ b/project/Settings.scala @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2017-2020 Lenses.io Ltd + */ + +import sbt.Keys._ +import sbt._ +import sbtassembly.AssemblyKeys._ +import sbtassembly.MergeStrategy + +object Settings extends Dependencies { + + val scala212 = "2.12.14" + val scala213 = "2.13.6" + + val nextVersion = "2.1.7" + val artifactVersion = { + sys.env.get("LENSES_TAG_NAME") match { + case Some(tag) => tag + case _ => s"$nextVersion-SNAPSHOT" + } + } + + object ScalacFlags { + val FatalWarnings212 = "-Xfatal-warnings" + val FatalWarnings213 = "-Werror" + val WarnUnusedImports212 = "-Ywarn-unused-import" + val WarnUnusedImports213 = "-Ywarn-unused:imports" + } + + val modulesSettings: Seq[Setting[_]] = Seq( + organization := "io.lenses", + version := artifactVersion, + scalaOrganization := "org.scala-lang", + resolvers ++= Seq( + Resolver.mavenLocal, + Resolver.mavenCentral + ), + libraryDependencies ++= secretProviderDeps, + crossScalaVersions := List(scala213, scala212), + Compile / scalacOptions ++= Seq( + "-target:jvm-1.8", + "-encoding", + "utf8", + "-deprecation", + "-unchecked", + "-feature", + "8" + ), + Compile / scalacOptions ++= { + CrossVersion.partialVersion(scalaVersion.value) match { + case Some((2, n)) if n <= 12 => + Seq( + "-Ypartial-unification", + ScalacFlags.WarnUnusedImports212 + ) + case _ => + Seq( + ScalacFlags.WarnUnusedImports213 + ) + } + }, + Compile / console / scalacOptions --= Seq( + ScalacFlags.FatalWarnings212, + ScalacFlags.FatalWarnings213, + ScalacFlags.WarnUnusedImports212, + ScalacFlags.WarnUnusedImports213 + ), + assembly / assemblyJarName := s"secret-provider_${SemanticVersioning(scalaVersion.value).majorMinor}-${artifactVersion}-all.jar", + assembly / assemblyExcludedJars := { + val cp = (assembly / fullClasspath).value + val excludes = Set( + "org.apache.avro", + "org.apache.kafka", + "io.confluent", + "org.apache.zookeeper" + ) + cp filter { f => excludes.exists(f.data.getName.contains(_)) } + }, + assembly / assemblyMergeStrategy := { + case "module-info.class" => MergeStrategy.discard + case x if x.contains("io.netty.versions.properties") => + MergeStrategy.concat + case x => + val oldStrategy = (assembly / assemblyMergeStrategy).value + oldStrategy(x) + }, + Test / fork := true + ) + +} diff --git a/project/Versioning.scala b/project/Versioning.scala new file mode 100644 index 0000000..e874a0c --- /dev/null +++ b/project/Versioning.scala @@ -0,0 +1,14 @@ +import java.util.regex.{Matcher, Pattern} + + +case class SemanticVersioning(version: String) { + + private val versionPattern: Pattern = Pattern.compile("([1-9]\\d*)\\.(\\d+)\\.(\\d+)(?:-([a-zA-Z0-9]+))?") + private val matcher: Matcher = versionPattern.matcher(version) + + def majorMinor = { + require(matcher.matches()) + s"${matcher.group(1)}.${matcher.group(2)}" + } + +} diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..bb5389d --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.5.5 \ No newline at end of file diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..72477a2 --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.15.0") diff --git a/src/main/scala/io/lenses/connect/secrets/async/AsyncFunctionLoop.scala b/src/main/scala/io/lenses/connect/secrets/async/AsyncFunctionLoop.scala index 2de7ca8..82640c4 100644 --- a/src/main/scala/io/lenses/connect/secrets/async/AsyncFunctionLoop.scala +++ b/src/main/scala/io/lenses/connect/secrets/async/AsyncFunctionLoop.scala @@ -6,13 +6,10 @@ package io.lenses.connect.secrets.async -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicLong - import com.typesafe.scalalogging.StrictLogging +import java.util.concurrent.atomic.{AtomicBoolean, AtomicLong} +import java.util.concurrent.{Executors, TimeUnit} import scala.concurrent.duration.Duration class AsyncFunctionLoop(interval: Duration, description: String)(thunk: => Unit) diff --git a/src/main/scala/io/lenses/connect/secrets/config/AWSProviderConfig.scala b/src/main/scala/io/lenses/connect/secrets/config/AWSProviderConfig.scala index 4af13e4..89ad183 100644 --- a/src/main/scala/io/lenses/connect/secrets/config/AWSProviderConfig.scala +++ b/src/main/scala/io/lenses/connect/secrets/config/AWSProviderConfig.scala @@ -6,12 +6,12 @@ package io.lenses.connect.secrets.config -import java.util - import io.lenses.connect.secrets.connect.{AuthMode, _} import org.apache.kafka.common.config.ConfigDef.{Importance, Type} import org.apache.kafka.common.config.{AbstractConfig, ConfigDef} +import java.util + object AWSProviderConfig { val AWS_REGION: String = "aws.region" diff --git a/src/main/scala/io/lenses/connect/secrets/config/AWSProviderSettings.scala b/src/main/scala/io/lenses/connect/secrets/config/AWSProviderSettings.scala index a5465c6..83fb0b9 100644 --- a/src/main/scala/io/lenses/connect/secrets/config/AWSProviderSettings.scala +++ b/src/main/scala/io/lenses/connect/secrets/config/AWSProviderSettings.scala @@ -19,7 +19,7 @@ case class AWSProviderSettings( fileDir: String ) -import AbstractConfigExtensions._ +import io.lenses.connect.secrets.config.AbstractConfigExtensions._ object AWSProviderSettings { def apply(configs: AWSProviderConfig): AWSProviderSettings = { val region = configs.getStringOrThrowOnNull(AWSProviderConfig.AWS_REGION) diff --git a/src/main/scala/io/lenses/connect/secrets/config/AbstractConfigProvider.scala b/src/main/scala/io/lenses/connect/secrets/config/AbstractConfigExtensions.scala similarity index 96% rename from src/main/scala/io/lenses/connect/secrets/config/AbstractConfigProvider.scala rename to src/main/scala/io/lenses/connect/secrets/config/AbstractConfigExtensions.scala index f4b3e19..bc8933b 100644 --- a/src/main/scala/io/lenses/connect/secrets/config/AbstractConfigProvider.scala +++ b/src/main/scala/io/lenses/connect/secrets/config/AbstractConfigExtensions.scala @@ -6,8 +6,8 @@ package io.lenses.connect.secrets.config -import org.apache.kafka.common.config.types.Password import org.apache.kafka.common.config.AbstractConfig +import org.apache.kafka.common.config.types.Password import org.apache.kafka.connect.errors.ConnectException object AbstractConfigExtensions { diff --git a/src/main/scala/io/lenses/connect/secrets/config/Aes256DecodingProviderConfig.scala b/src/main/scala/io/lenses/connect/secrets/config/Aes256ProviderConfig.scala similarity index 99% rename from src/main/scala/io/lenses/connect/secrets/config/Aes256DecodingProviderConfig.scala rename to src/main/scala/io/lenses/connect/secrets/config/Aes256ProviderConfig.scala index 717367e..1f3dd81 100644 --- a/src/main/scala/io/lenses/connect/secrets/config/Aes256DecodingProviderConfig.scala +++ b/src/main/scala/io/lenses/connect/secrets/config/Aes256ProviderConfig.scala @@ -1,8 +1,9 @@ package io.lenses.connect.secrets.config import io.lenses.connect.secrets.connect.{FILE_DIR, FILE_DIR_DESC} -import org.apache.kafka.common.config.{AbstractConfig, ConfigDef} import org.apache.kafka.common.config.ConfigDef.{Importance, Type} +import org.apache.kafka.common.config.{AbstractConfig, ConfigDef} + import java.util object Aes256ProviderConfig { diff --git a/src/main/scala/io/lenses/connect/secrets/config/AzureProviderConfig.scala b/src/main/scala/io/lenses/connect/secrets/config/AzureProviderConfig.scala index d05fcb4..dab074e 100644 --- a/src/main/scala/io/lenses/connect/secrets/config/AzureProviderConfig.scala +++ b/src/main/scala/io/lenses/connect/secrets/config/AzureProviderConfig.scala @@ -6,12 +6,12 @@ package io.lenses.connect.secrets.config -import java.util - import io.lenses.connect.secrets.connect._ import org.apache.kafka.common.config.ConfigDef.{Importance, Type} import org.apache.kafka.common.config.{AbstractConfig, ConfigDef} +import java.util + object AzureProviderConfig { val AZURE_CLIENT_ID = "azure.client.id" diff --git a/src/main/scala/io/lenses/connect/secrets/config/AzureProviderSettings.scala b/src/main/scala/io/lenses/connect/secrets/config/AzureProviderSettings.scala index cab4fec..b0d8fcf 100644 --- a/src/main/scala/io/lenses/connect/secrets/config/AzureProviderSettings.scala +++ b/src/main/scala/io/lenses/connect/secrets/config/AzureProviderSettings.scala @@ -20,7 +20,7 @@ case class AzureProviderSettings( fileDir: String ) -import AbstractConfigExtensions._ +import io.lenses.connect.secrets.config.AbstractConfigExtensions._ object AzureProviderSettings extends StrictLogging { def apply(config: AzureProviderConfig): AzureProviderSettings = { diff --git a/src/main/scala/io/lenses/connect/secrets/config/ENVProviderConfig.scala b/src/main/scala/io/lenses/connect/secrets/config/ENVProviderConfig.scala index fc8641e..f6aecae 100644 --- a/src/main/scala/io/lenses/connect/secrets/config/ENVProviderConfig.scala +++ b/src/main/scala/io/lenses/connect/secrets/config/ENVProviderConfig.scala @@ -6,11 +6,11 @@ package io.lenses.connect.secrets.config -import java.util - import io.lenses.connect.secrets.connect.{FILE_DIR, FILE_DIR_DESC} -import org.apache.kafka.common.config.{AbstractConfig, ConfigDef} import org.apache.kafka.common.config.ConfigDef.{Importance, Type} +import org.apache.kafka.common.config.{AbstractConfig, ConfigDef} + +import java.util object ENVProviderConfig { val config = new ConfigDef().define( diff --git a/src/main/scala/io/lenses/connect/secrets/config/VaultProviderConfig.scala b/src/main/scala/io/lenses/connect/secrets/config/VaultProviderConfig.scala index 3df6828..dc427aa 100644 --- a/src/main/scala/io/lenses/connect/secrets/config/VaultProviderConfig.scala +++ b/src/main/scala/io/lenses/connect/secrets/config/VaultProviderConfig.scala @@ -6,12 +6,12 @@ package io.lenses.connect.secrets.config -import java.util - import io.lenses.connect.secrets.connect.{FILE_DIR, FILE_DIR_DESC} import org.apache.kafka.common.config.ConfigDef.{Importance, Type} import org.apache.kafka.common.config.{AbstractConfig, ConfigDef, SslConfigs} +import java.util + object VaultAuthMethod extends Enumeration { type VaultAuthMethod = Value val KUBERNETES, AWSIAM, GCP, USERPASS, LDAP, JWT, CERT, APPROLE, TOKEN, diff --git a/src/main/scala/io/lenses/connect/secrets/config/VaultProviderSettings.scala b/src/main/scala/io/lenses/connect/secrets/config/VaultProviderSettings.scala index cdf4fec..deedd28 100644 --- a/src/main/scala/io/lenses/connect/secrets/config/VaultProviderSettings.scala +++ b/src/main/scala/io/lenses/connect/secrets/config/VaultProviderSettings.scala @@ -7,17 +7,16 @@ package io.lenses.connect.secrets.config import com.typesafe.scalalogging.StrictLogging +import io.lenses.connect.secrets.config.AbstractConfigExtensions._ import io.lenses.connect.secrets.config.VaultAuthMethod.VaultAuthMethod -import io.lenses.connect.secrets.connect._ -import AbstractConfigExtensions._ -import scala.concurrent.duration._ import io.lenses.connect.secrets.config.VaultProviderConfig.TOKEN_RENEWAL +import io.lenses.connect.secrets.connect._ import org.apache.kafka.common.config.types.Password import org.apache.kafka.connect.errors.ConnectException -import scala.concurrent.duration.FiniteDuration +import scala.concurrent.duration.{FiniteDuration, _} import scala.io.Source -import scala.util.{Failure, Success, Try} +import scala.util.{Failure, Success, Using} case class AwsIam( role: String, @@ -168,17 +167,15 @@ object VaultSettings extends StrictLogging { def getK8s(config: VaultProviderConfig): K8s = { val role = config.getStringOrThrowOnNull(VaultProviderConfig.KUBERNETES_ROLE) val path = config.getStringOrThrowOnNull(VaultProviderConfig.KUBERNETES_TOKEN_PATH) - val file = Try(Source.fromFile(path)) match { - case Success(file) => file + Using(Source.fromFile(path))(_.getLines().mkString) match { case Failure(exception) => throw new ConnectException( s"Failed to load kubernetes token file [$path]", exception ) + case Success(fileContents) => + K8s(role = role, jwt = new Password(fileContents)) } - val jwt = new Password(file.getLines.mkString) - file.close() - K8s(role = role, jwt = jwt) } def getUserPass(config: VaultProviderConfig): UserPass = { diff --git a/src/main/scala/io/lenses/connect/secrets/io/FileWriter.scala b/src/main/scala/io/lenses/connect/secrets/io/FileWriter.scala index 894a168..9ae31b5 100644 --- a/src/main/scala/io/lenses/connect/secrets/io/FileWriter.scala +++ b/src/main/scala/io/lenses/connect/secrets/io/FileWriter.scala @@ -5,18 +5,14 @@ */ package io.lenses.connect.secrets.io -import java.io.BufferedOutputStream -import java.io.FileOutputStream -import java.nio.file.Path -import java.nio.file.Paths - import com.typesafe.scalalogging.StrictLogging import io.lenses.connect.secrets.utils.WithRetry +import java.io.{BufferedOutputStream, FileOutputStream} +import java.nio.file.attribute.PosixFilePermissions +import java.nio.file.{Files, Path, Paths} import scala.concurrent.duration._ import scala.util.Try -import java.nio.file.attribute.PosixFilePermissions -import java.nio.file.Files trait FileWriter { def write(fileName: String, content: Array[Byte], key: String): Path diff --git a/src/main/scala/io/lenses/connect/secrets/package.scala b/src/main/scala/io/lenses/connect/secrets/package.scala index 8779641..d5931c2 100644 --- a/src/main/scala/io/lenses/connect/secrets/package.scala +++ b/src/main/scala/io/lenses/connect/secrets/package.scala @@ -6,20 +6,16 @@ package io.lenses.connect.secrets -import java.io.File -import java.io.FileOutputStream -import java.time.OffsetDateTime -import java.util.Base64 - import com.typesafe.scalalogging.StrictLogging import org.apache.kafka.common.config.ConfigData import org.apache.kafka.connect.errors.ConnectException -import scala.collection.JavaConverters._ +import java.io.{File, FileOutputStream} +import java.time.OffsetDateTime +import java.util.Base64 import scala.collection.mutable -import scala.util.Failure -import scala.util.Success -import scala.util.Try +import scala.jdk.CollectionConverters._ +import scala.util.{Failure, Success, Try} package object connect extends StrictLogging { @@ -146,7 +142,7 @@ package object connect extends StrictLogging { def getSecretsAndExpiry( secrets: Map[String, (String, Option[OffsetDateTime])] ): (Option[OffsetDateTime], ConfigData) = { - var expiryList = mutable.ListBuffer.empty[OffsetDateTime] + val expiryList = mutable.ListBuffer.empty[OffsetDateTime] val data = secrets .map({ diff --git a/src/main/scala/io/lenses/connect/secrets/providers/AWSHelper.scala b/src/main/scala/io/lenses/connect/secrets/providers/AWSHelper.scala index 26742ad..77a374e 100644 --- a/src/main/scala/io/lenses/connect/secrets/providers/AWSHelper.scala +++ b/src/main/scala/io/lenses/connect/secrets/providers/AWSHelper.scala @@ -6,28 +6,24 @@ package io.lenses.connect.secrets.providers -import java.nio.file.FileSystems -import java.nio.file.Paths -import java.time.OffsetDateTime -import java.util.Calendar - import com.amazonaws.auth.{AWSStaticCredentialsProvider, BasicAWSCredentials, DefaultAWSCredentialsProviderChain} import com.amazonaws.services.secretsmanager.model.{DescribeSecretRequest, GetSecretValueRequest} import com.amazonaws.services.secretsmanager.{AWSSecretsManager, AWSSecretsManagerClientBuilder} import com.fasterxml.jackson.databind.ObjectMapper import com.typesafe.scalalogging.StrictLogging import io.lenses.connect.secrets.config.AWSProviderSettings -import io.lenses.connect.secrets.connect.{decodeKey, getFileName, AuthMode} -import io.lenses.connect.secrets.io.FileWriter -import io.lenses.connect.secrets.io.FileWriterOnce +import io.lenses.connect.secrets.connect.{AuthMode, decodeKey} +import io.lenses.connect.secrets.io.{FileWriter, FileWriterOnce} import io.lenses.connect.secrets.utils.EncodingAndId import org.apache.kafka.connect.errors.ConnectException -import scala.collection.JavaConverters._ +import java.nio.file.Paths +import java.time.OffsetDateTime +import java.util.Calendar +import scala.jdk.CollectionConverters._ import scala.util.{Failure, Success, Try} trait AWSHelper extends StrictLogging { - private val separator: String = FileSystems.getDefault.getSeparator // initialize the AWS client based on the auth mode def createClient(settings: AWSProviderSettings): AWSSecretsManager = { @@ -129,7 +125,7 @@ trait AWSHelper extends StrictLogging { case Failure(exception) => throw new ConnectException( - s"Failed to look up key [$key] in secret [$secretId}]", + s"Failed to look up key [$key] in secret [$secretId] due to [${exception.getMessage}]", exception ) } diff --git a/src/main/scala/io/lenses/connect/secrets/providers/AWSSecretProvider.scala b/src/main/scala/io/lenses/connect/secrets/providers/AWSSecretProvider.scala index f1fa126..ea05000 100644 --- a/src/main/scala/io/lenses/connect/secrets/providers/AWSSecretProvider.scala +++ b/src/main/scala/io/lenses/connect/secrets/providers/AWSSecretProvider.scala @@ -6,17 +6,16 @@ package io.lenses.connect.secrets.providers -import java.time.OffsetDateTime -import java.util - +import com.amazonaws.services.secretsmanager.AWSSecretsManager import io.lenses.connect.secrets.config.{AWSProviderConfig, AWSProviderSettings} import io.lenses.connect.secrets.connect.getSecretsAndExpiry -import com.amazonaws.services.secretsmanager.AWSSecretsManager import org.apache.kafka.common.config.ConfigData import org.apache.kafka.common.config.provider.ConfigProvider import org.apache.kafka.connect.errors.ConnectException -import scala.collection.JavaConverters._ +import java.time.OffsetDateTime +import java.util +import scala.jdk.CollectionConverters._ class AWSSecretProvider() extends ConfigProvider with AWSHelper { diff --git a/src/main/scala/io/lenses/connect/secrets/providers/Aes256DecodingHelper.scala b/src/main/scala/io/lenses/connect/secrets/providers/Aes256DecodingHelper.scala index b1b948a..aab0663 100644 --- a/src/main/scala/io/lenses/connect/secrets/providers/Aes256DecodingHelper.scala +++ b/src/main/scala/io/lenses/connect/secrets/providers/Aes256DecodingHelper.scala @@ -2,13 +2,9 @@ package io.lenses.connect.secrets.providers import java.security.SecureRandom import java.util.Base64 - import javax.crypto.Cipher -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec - -import scala.util.Failure -import scala.util.Try +import javax.crypto.spec.{IvParameterSpec, SecretKeySpec} +import scala.util.{Failure, Try} private[providers] object Aes256DecodingHelper { diff --git a/src/main/scala/io/lenses/connect/secrets/providers/Aes256DecodingProvider.scala b/src/main/scala/io/lenses/connect/secrets/providers/Aes256DecodingProvider.scala index faa33e7..3753527 100644 --- a/src/main/scala/io/lenses/connect/secrets/providers/Aes256DecodingProvider.scala +++ b/src/main/scala/io/lenses/connect/secrets/providers/Aes256DecodingProvider.scala @@ -1,20 +1,16 @@ package io.lenses.connect.secrets.providers -import java.nio.file.Paths -import java.util - import io.lenses.connect.secrets.config.Aes256ProviderConfig import io.lenses.connect.secrets.connect.decodeKey -import io.lenses.connect.secrets.connect.Encoding -import io.lenses.connect.secrets.io.FileWriter -import io.lenses.connect.secrets.io.FileWriterOnce +import io.lenses.connect.secrets.io.{FileWriter, FileWriterOnce} import io.lenses.connect.secrets.utils.EncodingAndId -import org.apache.kafka.common.config.ConfigData import org.apache.kafka.common.config.provider.ConfigProvider -import org.apache.kafka.common.config.ConfigException +import org.apache.kafka.common.config.{ConfigData, ConfigException} import org.apache.kafka.connect.errors.ConnectException -import scala.collection.JavaConverters._ +import java.nio.file.Paths +import java.util +import scala.jdk.CollectionConverters._ class Aes256DecodingProvider extends ConfigProvider { diff --git a/src/main/scala/io/lenses/connect/secrets/providers/AzureHelper.scala b/src/main/scala/io/lenses/connect/secrets/providers/AzureHelper.scala index dc95a5c..2d43aa5 100644 --- a/src/main/scala/io/lenses/connect/secrets/providers/AzureHelper.scala +++ b/src/main/scala/io/lenses/connect/secrets/providers/AzureHelper.scala @@ -6,28 +6,16 @@ package io.lenses.connect.secrets.providers -import java.nio.file.FileSystems -import java.time.OffsetDateTime - import com.azure.core.credential.TokenCredential -import com.azure.identity.{ - ClientSecretCredentialBuilder, - DefaultAzureCredentialBuilder -} +import com.azure.identity.{ClientSecretCredentialBuilder, DefaultAzureCredentialBuilder} import com.azure.security.keyvault.secrets.SecretClient import com.typesafe.scalalogging.StrictLogging import io.lenses.connect.secrets.config.AzureProviderSettings -import io.lenses.connect.secrets.connect.{ - AuthMode, - Encoding, - FILE_ENCODING, - decode, - decodeToBytes, - fileWriter, - getFileName -} +import io.lenses.connect.secrets.connect._ import org.apache.kafka.connect.errors.ConnectException +import java.nio.file.FileSystems +import java.time.OffsetDateTime import scala.util.{Failure, Success, Try} trait AzureHelper extends StrictLogging { diff --git a/src/main/scala/io/lenses/connect/secrets/providers/AzureSecretProvider.scala b/src/main/scala/io/lenses/connect/secrets/providers/AzureSecretProvider.scala index 3b2bcb5..d2e114b 100644 --- a/src/main/scala/io/lenses/connect/secrets/providers/AzureSecretProvider.scala +++ b/src/main/scala/io/lenses/connect/secrets/providers/AzureSecretProvider.scala @@ -6,21 +6,17 @@ package io.lenses.connect.secrets.providers -import java.time.OffsetDateTime -import java.util - import com.azure.core.credential.TokenCredential import com.azure.security.keyvault.secrets.{SecretClient, SecretClientBuilder} -import io.lenses.connect.secrets.config.{ - AzureProviderConfig, - AzureProviderSettings -} +import io.lenses.connect.secrets.config.{AzureProviderConfig, AzureProviderSettings} import io.lenses.connect.secrets.connect.getSecretsAndExpiry import org.apache.kafka.common.config.ConfigData import org.apache.kafka.common.config.provider.ConfigProvider -import scala.collection.JavaConverters._ +import java.time.OffsetDateTime +import java.util import scala.collection.mutable +import scala.jdk.CollectionConverters._ class AzureSecretProvider() extends ConfigProvider with AzureHelper { @@ -75,7 +71,9 @@ class AzureSecretProvider() extends ConfigProvider with AzureHelper { logger.info("Fetching secrets from cache") (expiresAt, new ConfigData( - data.data().asScala.filterKeys(k => keys.contains(k)).asJava, + data.data().asScala.view.filter{ + case (k,_) => keys.contains(k) + }.toMap.asJava, data.ttl())) } else { // missing some or expired so reload diff --git a/src/main/scala/io/lenses/connect/secrets/providers/ENVSecretProvider.scala b/src/main/scala/io/lenses/connect/secrets/providers/ENVSecretProvider.scala index b9de025..3bfdf68 100644 --- a/src/main/scala/io/lenses/connect/secrets/providers/ENVSecretProvider.scala +++ b/src/main/scala/io/lenses/connect/secrets/providers/ENVSecretProvider.scala @@ -6,16 +6,15 @@ package io.lenses.connect.secrets.providers -import java.nio.file.FileSystems -import java.util - import io.lenses.connect.secrets.config.ENVProviderConfig import io.lenses.connect.secrets.connect.{FILE_DIR, decode, decodeToBytes, fileWriter} import org.apache.kafka.common.config.ConfigData import org.apache.kafka.common.config.provider.ConfigProvider import org.apache.kafka.connect.errors.ConnectException -import scala.collection.JavaConverters._ +import java.nio.file.FileSystems +import java.util +import scala.jdk.CollectionConverters._ class ENVSecretProvider extends ConfigProvider { @@ -42,18 +41,18 @@ class ENVSecretProvider extends ConfigProvider { // match the value to see if its coming from contains // the value metadata pattern envVarVal match { - case BASE64_FILE(m, v) => + case BASE64_FILE(_, v) => //decode and write to file - val fileName = s"${fileDir}$separator${key.toLowerCase}" + val fileName = s"$fileDir$separator${key.toLowerCase}" fileWriter(fileName, decodeToBytes(key, v), key) (key, fileName) - case UTF8_FILE(m, v) => - val fileName = s"${fileDir}$separator${key.toLowerCase}" + case UTF8_FILE(_, v) => + val fileName = s"$fileDir$separator${key.toLowerCase}" fileWriter(fileName, v.getBytes(), key) (key, fileName) - case BASE64(m, v) => + case BASE64(_, v) => (key, decode(key, v)) case _ => diff --git a/src/main/scala/io/lenses/connect/secrets/providers/VaultHelper.scala b/src/main/scala/io/lenses/connect/secrets/providers/VaultHelper.scala index 10411f9..10e2e1f 100644 --- a/src/main/scala/io/lenses/connect/secrets/providers/VaultHelper.scala +++ b/src/main/scala/io/lenses/connect/secrets/providers/VaultHelper.scala @@ -6,13 +6,13 @@ package io.lenses.connect.secrets.providers -import java.io.File - import com.bettercloud.vault.{SslConfig, Vault, VaultConfig} import com.typesafe.scalalogging.StrictLogging import io.lenses.connect.secrets.config.{VaultAuthMethod, VaultSettings} import org.apache.kafka.connect.errors.ConnectException +import java.io.File + trait VaultHelper extends StrictLogging { // initialize the vault client diff --git a/src/main/scala/io/lenses/connect/secrets/providers/VaultSecretProvider.scala b/src/main/scala/io/lenses/connect/secrets/providers/VaultSecretProvider.scala index 3d4f9a0..685d2bd 100644 --- a/src/main/scala/io/lenses/connect/secrets/providers/VaultSecretProvider.scala +++ b/src/main/scala/io/lenses/connect/secrets/providers/VaultSecretProvider.scala @@ -6,14 +6,8 @@ package io.lenses.connect.secrets.providers -import java.nio.file.FileSystems -import java.nio.file.Paths -import java.time.OffsetDateTime -import java.util - -import _root_.io.lenses.connect.secrets.config.VaultProviderConfig -import _root_.io.lenses.connect.secrets.config.VaultSettings -import _root_.io.lenses.connect.secrets.connect._ +import io.lenses.connect.secrets.config.{VaultProviderConfig, VaultSettings} +import io.lenses.connect.secrets.connect._ import com.bettercloud.vault.Vault import io.lenses.connect.secrets.async.AsyncFunctionLoop import io.lenses.connect.secrets.io.FileWriterOnce @@ -22,15 +16,15 @@ import org.apache.kafka.common.config.ConfigData import org.apache.kafka.common.config.provider.ConfigProvider import org.apache.kafka.connect.errors.ConnectException -import scala.collection.JavaConverters._ +import java.nio.file.Paths +import java.time.OffsetDateTime +import java.util import scala.collection.mutable -import scala.util.Failure -import scala.util.Success -import scala.util.Try +import scala.jdk.CollectionConverters._ +import scala.util.{Failure, Success, Try} class VaultSecretProvider() extends ConfigProvider with VaultHelper { - private val separator: String = FileSystems.getDefault.getSeparator private var settings: VaultSettings = _ private var vaultClient: Option[Vault] = None private var tokenRenewal: Option[AsyncFunctionLoop] = None @@ -94,16 +88,22 @@ class VaultSecretProvider() extends ConfigProvider with VaultHelper { logger.info("Fetching secrets from cache") (expiresAt, new ConfigData( - data.data().asScala.filterKeys(k => keys.contains(k)).asJava, + data.data().asScala.view.filter{ + case (k,_) => keys.contains(k) + }.toMap.asJava, data.ttl())) } else { // missing some or expired so reload getSecretsAndExpiry( - getSecrets(path).filterKeys(k => keys.contains(k))) + getSecrets(path).view.filter{ + case (k,_) => keys.contains(k) + }.toMap) } case None => - getSecretsAndExpiry(getSecrets(path).filterKeys(k => keys.contains(k))) + getSecretsAndExpiry(getSecrets(path).view.filter{ + case (k,_) => keys.contains(k) + }.toMap) } expiry.foreach(exp => diff --git a/src/main/scala/io/lenses/connect/secrets/utils/WithRetry.scala b/src/main/scala/io/lenses/connect/secrets/utils/WithRetry.scala index 2bf0340..b937d8b 100644 --- a/src/main/scala/io/lenses/connect/secrets/utils/WithRetry.scala +++ b/src/main/scala/io/lenses/connect/secrets/utils/WithRetry.scala @@ -7,20 +7,18 @@ package io.lenses.connect.secrets.utils import scala.annotation.tailrec import scala.concurrent.duration.FiniteDuration +import scala.util.{Failure, Success, Try} trait WithRetry { + + @tailrec protected final def withRetry[T](retry: Int = 5, interval:Option[FiniteDuration])(thunk: => T): T = - try { + Try { thunk - } catch { - case t: Throwable => - if (retry == 0) throw t - interval match { - case Some(value) => - Thread.sleep(value.toMillis) - withRetry(retry - 1, interval)(thunk) - case None => - withRetry(retry-1,interval)(thunk) - } + } match { + case Failure(t) => if (retry == 0) throw t + interval.foreach(sleepValue => Thread.sleep(sleepValue.toMillis)) + withRetry(retry - 1, interval)(thunk) + case Success(value) => value } } diff --git a/src/test/java/io/lenses/connect/secrets/vault/VaultTestUtils.java b/src/test/java/io/lenses/connect/secrets/vault/VaultTestUtils.java index d41884d..0d8268d 100644 --- a/src/test/java/io/lenses/connect/secrets/vault/VaultTestUtils.java +++ b/src/test/java/io/lenses/connect/secrets/vault/VaultTestUtils.java @@ -78,9 +78,6 @@ public static Optional readRequestBody(HttpServletRequest request) { } public static Map readRequestHeaders(HttpServletRequest request) { -// ArrayList x = new ArrayList<>(); -// x.add(request.getHeaderNames()).stream().collect(toMap(identity(), request::getHeader)); -// return Collections.list(request.getHeaderNames()).stream().collect(toMap(identity(), request::getHeader)); } diff --git a/src/test/scala/io/lenses/connect/secrets/async/AsyncFunctionLoopTest.scala b/src/test/scala/io/lenses/connect/secrets/async/AsyncFunctionLoopTest.scala index 095fe27..d0b0c22 100644 --- a/src/test/scala/io/lenses/connect/secrets/async/AsyncFunctionLoopTest.scala +++ b/src/test/scala/io/lenses/connect/secrets/async/AsyncFunctionLoopTest.scala @@ -6,12 +6,10 @@ package io.lenses.connect.secrets.async -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit - import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers +import java.util.concurrent.{CountDownLatch, TimeUnit} import scala.concurrent.duration.DurationInt class AsyncFunctionLoopTest extends AnyFunSuite with Matchers { diff --git a/src/test/scala/io/lenses/connect/secrets/io/FileWriterOnceTest.scala b/src/test/scala/io/lenses/connect/secrets/io/FileWriterOnceTest.scala index 9ba13f6..22d09c9 100644 --- a/src/test/scala/io/lenses/connect/secrets/io/FileWriterOnceTest.scala +++ b/src/test/scala/io/lenses/connect/secrets/io/FileWriterOnceTest.scala @@ -1,15 +1,14 @@ package io.lenses.connect.secrets.io -import java.io.File -import java.nio.file.Paths -import java.util.UUID - +import org.scalatest.BeforeAndAfterAll import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers -import org.scalatest.BeforeAndAfterAll +import java.io.File +import java.nio.file.Paths +import java.util.UUID import scala.io.Source -import scala.util.Try +import scala.util.{Success, Try, Using} class FileWriterOnceTest extends AnyFunSuite @@ -34,10 +33,7 @@ class FileWriterOnceTest val file = Paths.get(folder.toPath.toString, "thisone").toFile file.exists() shouldBe true - val source = Source.fromFile(file) - val size = source.size - source.close() - size shouldBe content.length + Using(Source.fromFile(file))(_.size) shouldBe Success(content.length) } test("does not write the file twice") { @@ -47,16 +43,11 @@ class FileWriterOnceTest val file = Paths.get(folder.toPath.toString, fileName).toFile file.exists() shouldBe true - var source = Source.fromFile(file) - var size = source.size - source.close() - size shouldBe content1.length + Using(Source.fromFile(file))(_.size) shouldBe Success(content1.length) val content2 = Array(1, 2, 3,4,5,6,7,8).map(_.toByte) writer.write(fileName, content2, "key1") - source = Source.fromFile(file) - size = source.size - source.close() - size shouldBe content1.length + Using(Source.fromFile(file))(_.size) shouldBe Success(content1.length) + } } diff --git a/src/test/scala/io/lenses/connect/secrets/providers/AWSSecretProviderTest.scala b/src/test/scala/io/lenses/connect/secrets/providers/AWSSecretProviderTest.scala index c4b17ce..8475778 100644 --- a/src/test/scala/io/lenses/connect/secrets/providers/AWSSecretProviderTest.scala +++ b/src/test/scala/io/lenses/connect/secrets/providers/AWSSecretProviderTest.scala @@ -6,18 +6,11 @@ package io.lenses.connect.secrets.providers -import java.nio.file.FileSystems -import java.util.Base64 -import java.util.Date - import com.amazonaws.services.secretsmanager.AWSSecretsManager import com.amazonaws.services.secretsmanager.model._ import com.bettercloud.vault.json.JsonObject -import io.lenses.connect.secrets.config.AWSProviderConfig -import io.lenses.connect.secrets.config.AWSProviderSettings -import io.lenses.connect.secrets.connect._ -import io.lenses.connect.secrets.connect.AuthMode -import io.lenses.connect.secrets.connect.Encoding +import io.lenses.connect.secrets.config.{AWSProviderConfig, AWSProviderSettings} +import io.lenses.connect.secrets.connect.{AuthMode, Encoding, _} import io.lenses.connect.secrets.utils.EncodingAndId import org.apache.kafka.common.config.ConfigTransformer import org.apache.kafka.common.config.provider.ConfigProvider @@ -27,8 +20,11 @@ import org.mockito.MockitoSugar import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpec -import scala.collection.JavaConverters._ +import java.nio.file.FileSystems +import java.util.{Base64, Date} import scala.io.Source +import scala.jdk.CollectionConverters._ +import scala.util.{Success, Using} class AWSSecretProviderTest extends AnyWordSpec @@ -192,9 +188,7 @@ class AWSSecretProviderTest val outputFile = data.data().get(secretKey) outputFile shouldBe s"$tmp$separator$secretName$separator${secretKey.toLowerCase}" - val result = Source.fromFile(outputFile) - result.getLines().mkString shouldBe secretValue - result.close() + Using(Source.fromFile(outputFile))(_.getLines().mkString) shouldBe Success(secretValue) provider.get("").data().isEmpty shouldBe true provider.close() @@ -248,9 +242,7 @@ class AWSSecretProviderTest val outputFile = data.data().get(secretKey) outputFile shouldBe s"$tmp$separator$secretName$separator${secretKey.toLowerCase}" - val result = Source.fromFile(outputFile) - result.getLines().mkString shouldBe secretValue - result.close() + Using(Source.fromFile(outputFile))(_.getLines().mkString) shouldBe Success(secretValue) provider.get("").data().isEmpty shouldBe true provider.close() diff --git a/src/test/scala/io/lenses/connect/secrets/providers/Aes256DecodingHelperTest.scala b/src/test/scala/io/lenses/connect/secrets/providers/Aes256DecodingHelperTest.scala index ad8404e..40e4408 100644 --- a/src/test/scala/io/lenses/connect/secrets/providers/Aes256DecodingHelperTest.scala +++ b/src/test/scala/io/lenses/connect/secrets/providers/Aes256DecodingHelperTest.scala @@ -1,13 +1,13 @@ package io.lenses.connect.secrets.providers +import io.lenses.connect.secrets.providers.Aes256DecodingHelper.INITIALISATION_VECTOR_SEPARATOR import org.scalatest.matchers.should.Matchers -import org.scalatest.wordspec.AnyWordSpec import org.scalatest.prop.TableDrivenPropertyChecks +import org.scalatest.wordspec.AnyWordSpec + +import java.util.UUID.randomUUID import scala.util.Random.nextString -import scala.util.Try -import Aes256DecodingHelper.INITIALISATION_VECTOR_SEPARATOR import scala.util.Success -import java.util.UUID.randomUUID class Aes256DecodingHelperTest extends AnyWordSpec @@ -19,7 +19,7 @@ class Aes256DecodingHelperTest "AES-256 decorer" should { "not be created for invalid key length" in { val secretKey = randomUUID.toString.take(16) - Aes256DecodingHelper.init(secretKey) shouldBe 'left + Aes256DecodingHelper.init(secretKey) shouldBe Symbol("left") } "not be able to decrypt message for uncrecognized key" in new TestContext { diff --git a/src/test/scala/io/lenses/connect/secrets/providers/Aes256DecodingProviderTest.scala b/src/test/scala/io/lenses/connect/secrets/providers/Aes256DecodingProviderTest.scala index 282a768..561bfea 100644 --- a/src/test/scala/io/lenses/connect/secrets/providers/Aes256DecodingProviderTest.scala +++ b/src/test/scala/io/lenses/connect/secrets/providers/Aes256DecodingProviderTest.scala @@ -1,32 +1,23 @@ package io.lenses.connect.secrets.providers +import io.lenses.connect.secrets.config.Aes256ProviderConfig +import io.lenses.connect.secrets.connect.{Encoding, FILE_DIR} +import io.lenses.connect.secrets.utils.EncodingAndId import org.apache.kafka.common.config.provider.ConfigProvider +import org.apache.kafka.common.config.{ConfigException, ConfigTransformer} +import org.apache.kafka.connect.errors.ConnectException import org.scalatest.matchers.should.Matchers import org.scalatest.prop.TableDrivenPropertyChecks import org.scalatest.wordspec.AnyWordSpec -import java.util.Base64 -import java.util.UUID.randomUUID - -import io.lenses.connect.secrets.config.Aes256ProviderConfig - -import scala.collection.JavaConverters._ -import org.apache.kafka.connect.errors.ConnectException -import org.apache.kafka.common.config.ConfigException -import org.apache.kafka.common.config.ConfigTransformer -import io.lenses.connect.secrets.connect.FILE_DIR -import java.io.File -import java.nio.file.Files -import org.scalatest.compatible.Assertion - -import scala.io.Source import java.io.FileInputStream +import java.nio.file.Files import java.util - -import io.lenses.connect.secrets.connect.Encoding -import io.lenses.connect.secrets.utils.EncodingAndId - -import scala.util.Random +import java.util.Base64 +import java.util.UUID.randomUUID +import scala.io.Source +import scala.jdk.CollectionConverters._ +import scala.util.{Random, Success, Using} class Aes256DecodingProviderTest extends AnyWordSpec @@ -61,7 +52,7 @@ class Aes256DecodingProviderTest decryptedPath should startWith(s"$tmpDir/secrets/") decryptedPath.toLowerCase.contains(encrypted.toLowerCase) shouldBe false - Source.fromFile(decryptedPath).getLines.mkString shouldBe value + Using(Source.fromFile(decryptedPath))(_.getLines().mkString) shouldBe Success(value) } "decrypt aes 256 encoded value stored in file with base64 encoding" in new TestContext with ConfiguredProvider { diff --git a/src/test/scala/io/lenses/connect/secrets/providers/AesDecodingTestHelper.scala b/src/test/scala/io/lenses/connect/secrets/providers/AesDecodingTestHelper.scala index 0f2bcbb..ec3137c 100644 --- a/src/test/scala/io/lenses/connect/secrets/providers/AesDecodingTestHelper.scala +++ b/src/test/scala/io/lenses/connect/secrets/providers/AesDecodingTestHelper.scala @@ -1,11 +1,11 @@ package io.lenses.connect.secrets.providers -import javax.crypto.Cipher -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec +import io.lenses.connect.secrets.providers.Aes256DecodingHelper.INITIALISATION_VECTOR_SEPARATOR + import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.spec.{IvParameterSpec, SecretKeySpec} import scala.util.Try -import Aes256DecodingHelper.INITIALISATION_VECTOR_SEPARATOR object AesDecodingTestHelper { private val AES = "AES" diff --git a/src/test/scala/io/lenses/connect/secrets/providers/AzureSecretProviderTest.scala b/src/test/scala/io/lenses/connect/secrets/providers/AzureSecretProviderTest.scala index 7331e7d..4afb193 100644 --- a/src/test/scala/io/lenses/connect/secrets/providers/AzureSecretProviderTest.scala +++ b/src/test/scala/io/lenses/connect/secrets/providers/AzureSecretProviderTest.scala @@ -6,24 +6,24 @@ package io.lenses.connect.secrets.providers -import java.nio.file.FileSystems -import java.time.OffsetDateTime -import java.util.Base64 - import com.azure.security.keyvault.secrets.SecretClient import com.azure.security.keyvault.secrets.models.{KeyVaultSecret, SecretProperties} import io.lenses.connect.secrets.config.{AzureProviderConfig, AzureProviderSettings} import io.lenses.connect.secrets.connect import io.lenses.connect.secrets.connect.AuthMode -import org.apache.kafka.common.config.{ConfigData, ConfigDef, ConfigTransformer} import org.apache.kafka.common.config.provider.ConfigProvider +import org.apache.kafka.common.config.{ConfigData, ConfigTransformer} import org.apache.kafka.connect.errors.ConnectException import org.mockito.MockitoSugar import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpec -import scala.collection.JavaConverters._ +import java.nio.file.FileSystems +import java.time.OffsetDateTime +import java.util.Base64 import scala.io.Source +import scala.jdk.CollectionConverters._ +import scala.util.{Success, Using} class AzureSecretProviderTest extends AnyWordSpec @@ -153,9 +153,7 @@ class AzureSecretProviderTest outputFile shouldBe s"$tmp$separator$secretPath$separator$secretKey" - val result = Source.fromFile(outputFile) - result.getLines().mkString shouldBe secretValue - result.close() + Using(Source.fromFile(outputFile))(_.getLines().mkString) shouldBe Success(secretValue) provider.get("").data().isEmpty shouldBe true provider.close() @@ -199,9 +197,7 @@ class AzureSecretProviderTest outputFile shouldBe s"$tmp$separator$secretPath$separator$secretKey" - val result = Source.fromFile(outputFile) - result.getLines().mkString shouldBe secretValue - result.close() + Using(Source.fromFile(outputFile))(_.getLines().mkString) shouldBe Success(secretValue) provider.get("").data().isEmpty shouldBe true provider.close() @@ -220,7 +216,6 @@ class AzureSecretProviderTest provider.configure(props) val secretKey = "utf8-key" - val secretValue = "utf8-secret-value" val secretPath = "my-path.vault.azure.net" val client = mock[SecretClient] diff --git a/src/test/scala/io/lenses/connect/secrets/providers/DecodeTest.scala b/src/test/scala/io/lenses/connect/secrets/providers/DecodeTest.scala index 50e4168..eff5307 100644 --- a/src/test/scala/io/lenses/connect/secrets/providers/DecodeTest.scala +++ b/src/test/scala/io/lenses/connect/secrets/providers/DecodeTest.scala @@ -6,17 +6,17 @@ package io.lenses.connect.secrets.providers -import java.io.File -import java.nio.file.FileSystems -import java.time.OffsetDateTime -import java.util.Base64 - import io.lenses.connect.secrets.connect import io.lenses.connect.secrets.connect.Encoding import org.apache.commons.io.FileUtils import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpec +import java.io.File +import java.nio.file.FileSystems +import java.time.OffsetDateTime +import java.util.Base64 + class DecodeTest extends AnyWordSpec with Matchers { val separator: String = FileSystems.getDefault.getSeparator diff --git a/src/test/scala/io/lenses/connect/secrets/providers/ENVSecretProviderTest.scala b/src/test/scala/io/lenses/connect/secrets/providers/ENVSecretProviderTest.scala index 34d2168..07a4f5f 100644 --- a/src/test/scala/io/lenses/connect/secrets/providers/ENVSecretProviderTest.scala +++ b/src/test/scala/io/lenses/connect/secrets/providers/ENVSecretProviderTest.scala @@ -6,16 +6,16 @@ package io.lenses.connect.secrets.providers -import java.nio.file.FileSystems -import java.util.Base64 - import org.apache.kafka.common.config.ConfigTransformer import org.apache.kafka.common.config.provider.ConfigProvider import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpec -import scala.collection.JavaConverters._ +import java.nio.file.FileSystems +import java.util.Base64 import scala.io.Source +import scala.jdk.CollectionConverters._ +import scala.util.{Success, Using} class ENVSecretProviderTest extends AnyWordSpec with Matchers { @@ -49,24 +49,18 @@ class ENVSecretProviderTest extends AnyWordSpec with Matchers { val outputFile = data4.data().get("BASE64_FILE") outputFile shouldBe s"$tmp${separator}base64_file" - val result = Source.fromFile(outputFile) - result.getLines().mkString shouldBe "my-base64-secret" - result.close() + Using(Source.fromFile(outputFile))(_.getLines().mkString) shouldBe Success("my-base64-secret") val data5 = provider.get("", Set("UTF8_FILE").asJava) val outputFile5 = data5.data().get("UTF8_FILE") outputFile5 shouldBe s"$tmp${separator}utf8_file" - val result2 = Source.fromFile(outputFile5) - result2.getLines().mkString shouldBe "my-secret" - result2.close() + Using(Source.fromFile(outputFile5))(_.getLines().mkString) shouldBe Success("my-secret") } "check transformer" in { - val props = Map.empty[String, String].asJava - val provider = new ENVSecretProvider() provider.vars = Map("CONNECT_PASSWORD" -> "secret") diff --git a/src/test/scala/io/lenses/connect/secrets/providers/VaultSecretProviderTest.scala b/src/test/scala/io/lenses/connect/secrets/providers/VaultSecretProviderTest.scala index bda5472..9522528 100644 --- a/src/test/scala/io/lenses/connect/secrets/providers/VaultSecretProviderTest.scala +++ b/src/test/scala/io/lenses/connect/secrets/providers/VaultSecretProviderTest.scala @@ -6,28 +6,23 @@ package io.lenses.connect.secrets.providers -import java.io.File -import java.nio.file.FileSystems -import java.util.Base64 - -import com.bettercloud.vault.json.JsonArray -import com.bettercloud.vault.json.JsonObject -import io.lenses.connect.secrets.config.VaultAuthMethod -import io.lenses.connect.secrets.config.VaultProviderConfig -import io.lenses.connect.secrets.config.VaultSettings +import com.bettercloud.vault.json.{JsonArray, JsonObject} +import io.lenses.connect.secrets.config.{VaultAuthMethod, VaultProviderConfig, VaultSettings} import io.lenses.connect.secrets.connect -import io.lenses.connect.secrets.vault.MockVault -import io.lenses.connect.secrets.vault.VaultTestUtils -import org.apache.kafka.common.config.ConfigData -import org.apache.kafka.common.config.ConfigTransformer +import io.lenses.connect.secrets.vault.{MockVault, VaultTestUtils} import org.apache.kafka.common.config.provider.ConfigProvider +import org.apache.kafka.common.config.{ConfigData, ConfigTransformer} import org.eclipse.jetty.server.Server import org.scalatest.BeforeAndAfterAll import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpec -import scala.collection.JavaConverters._ +import java.io.File +import java.nio.file.FileSystems +import java.util.Base64 import scala.io.Source +import scala.jdk.CollectionConverters._ +import scala.util.{Success, Using} class VaultSecretProviderTest extends AnyWordSpec @@ -333,9 +328,7 @@ class VaultSecretProviderTest .get(secretKey) outputFile shouldBe s"$tmp$separator$secretPath$separator$secretKey" - val result = Source.fromFile(outputFile) - result.getLines().mkString shouldBe secretValue - result.close() + Using(Source.fromFile(outputFile))(_.getLines().mkString) shouldBe Success(secretValue) provider.close() } @@ -363,9 +356,7 @@ class VaultSecretProviderTest .get(secretKey) outputFile shouldBe s"$tmp$separator$secretPath$separator$secretKey" - val result = Source.fromFile(outputFile) - result.getLines().mkString shouldBe secretValue - result.close() + Using(Source.fromFile(outputFile))(_.getLines().mkString) shouldBe Success(secretValue) provider.close() } From bcb762872167440ba2432abe961f2932fddd0621 Mon Sep 17 00:00:00 2001 From: David Sloan Date: Thu, 19 Aug 2021 05:56:07 +0100 Subject: [PATCH 2/3] code review .gitignore changes --- .gitignore | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 220a21b..5129bb5 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,5 @@ /build/ /.classpath /.project -/target/ -/project/target/ +target/ /.bsp/ -/project/project/target/ From 64a603e43b63b37b98decd6b7b251e165278c0af Mon Sep 17 00:00:00 2001 From: David Sloan Date: Thu, 23 Feb 2023 12:46:12 +0000 Subject: [PATCH 3/3] Dep upgrades, test fixes.CFixes --- .gitignore | 1 + build.sbt | 15 +- project/Dependencies.scala | 79 +- project/Settings.scala | 11 +- project/Versioning.scala | 4 +- project/build.properties | 2 +- project/plugins.sbt | 2 + .../secrets/async/AsyncFunctionLoop.scala | 9 +- .../secrets/config/AWSProviderConfig.scala | 128 +-- .../secrets/config/AWSProviderSettings.scala | 108 +-- .../config/AbstractConfigExtensions.scala | 58 +- .../secrets/config/Aes256ProviderConfig.scala | 10 +- .../secrets/config/AzureProviderConfig.scala | 130 +-- .../config/AzureProviderSettings.scala | 126 +-- .../secrets/config/ENVProviderConfig.scala | 52 +- .../secrets/config/VaultProviderConfig.scala | 725 +++++++-------- .../config/VaultProviderSettings.scala | 422 ++++----- .../connect/secrets/io/FileWriter.scala | 11 +- .../io/lenses/connect/secrets/package.scala | 344 +++---- .../connect/secrets/providers/AWSHelper.scala | 284 +++--- .../secrets/providers/AWSSecretProvider.scala | 129 +-- .../providers/Aes256DecodingHelper.scala | 27 +- .../providers/Aes256DecodingProvider.scala | 23 +- .../secrets/providers/AzureHelper.scala | 216 ++--- .../providers/AzureSecretProvider.scala | 224 ++--- .../secrets/providers/ENVSecretProvider.scala | 160 ++-- .../secrets/providers/VaultHelper.scala | 330 +++---- .../providers/VaultSecretProvider.scala | 74 +- .../connect/secrets/utils/WithRetry.scala | 8 +- .../connect/secrets/vault/MockVault.java | 6 +- .../connect/secrets/vault/VaultTestUtils.java | 2 +- .../lenses/connect/secrets/TmpDirUtil.scala | 11 + .../secrets/io/FileWriterOnceTest.scala | 2 +- .../providers/AWSSecretProviderTest.scala | 667 +++++++------- .../providers/Aes256DecodingHelperTest.scala | 6 +- .../Aes256DecodingProviderTest.scala | 39 +- .../providers/AesDecodingTestHelper.scala | 60 +- .../providers/AzureSecretProviderTest.scala | 852 +++++++++--------- .../secrets/providers/DecodeTest.scala | 218 ++--- .../providers/ENVSecretProviderTest.scala | 161 ++-- .../providers/VaultSecretProviderTest.scala | 27 +- 41 files changed, 2979 insertions(+), 2784 deletions(-) create mode 100644 src/test/scala/io/lenses/connect/secrets/TmpDirUtil.scala diff --git a/.gitignore b/.gitignore index 5129bb5..aab635d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ /.project target/ /.bsp/ +.DS_Store diff --git a/build.sbt b/build.sbt index 9b8f3cc..49b7e0b 100644 --- a/build.sbt +++ b/build.sbt @@ -1,11 +1,16 @@ import Settings.modulesSettings name := "secret-provider" - -javaOptions ++= Seq("-Xms512M", "-Xmx2048M", "-XX:+CMSClassUnloadingEnabled") +javacOptions ++= Seq("--release", "11") +javaOptions ++= Seq("-Xms512M", "-Xmx2048M") lazy val root = (project in file(".")) .settings(modulesSettings) - - - +addCommandAlias( + "validateAll", + ";scalafmtCheck;scalafmtSbtCheck;test:scalafmtCheck;" +) +addCommandAlias( + "formatAll", + ";scalafmt;scalafmtSbt;test:scalafmt;" +) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index d93690d..1d63148 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -8,24 +8,25 @@ trait Dependencies { object Versions { - val scalaLoggingVersion = "3.9.4" - val kafkaVersion = "2.8.0" + val scalaLoggingVersion = "3.9.5" + val kafkaVersion = "3.4.0" val vaultVersion = "5.1.0" - val azureKeyVaultVersion = "4.1.1" - val azureIdentityVersion = "1.0.5" - val awsSecretsVersion = "1.12.29" + val azureKeyVaultVersion = "4.5.2" + val azureIdentityVersion = "1.8.0" + val awsSecretsVersion = "1.12.411" //test - val scalaTestVersion = "3.1.1" - val mockitoVersion = "1.13.0" - val byteBuddyVersion = "1.11.9" - val slf4jVersion = "1.7.26" + val scalaTestVersion = "3.2.15" + val mockitoVersion = "3.2.15.0" + val byteBuddyVersion = "1.14.0" + val slf4jVersion = "2.0.5" val commonsIOVersion = "1.3.2" - val jettyVersion = "9.4.19.v20190610" + val jettyVersion = "11.0.13" val testContainersVersion = "1.12.3" - val flexmarkVersion = "0.35.10" + val flexmarkVersion = "0.64.0" - val scalaCollectionCompatVersion = "2.5.0" + val scalaCollectionCompatVersion = "2.8.1" + val jakartaServletVersion = "6.0.0" } @@ -33,43 +34,51 @@ trait Dependencies { import Versions._ - val `scala-logging` = "com.typesafe.scala-logging" %% "scala-logging" % scalaLoggingVersion + val `scala-logging` = + "com.typesafe.scala-logging" %% "scala-logging" % scalaLoggingVersion val `kafka-connect-api` = "org.apache.kafka" % "connect-api" % kafkaVersion - val `vault-java-driver` = "com.bettercloud" % "vault-java-driver" % vaultVersion - val `azure-key-vault` = "com.azure" % "azure-security-keyvault-secrets" % azureKeyVaultVersion + val `vault-java-driver` = + "com.bettercloud" % "vault-java-driver" % vaultVersion + val `azure-key-vault` = + "com.azure" % "azure-security-keyvault-secrets" % azureKeyVaultVersion val `azure-identity` = "com.azure" % "azure-identity" % azureIdentityVersion - val `aws-secrets-manager` = "com.amazonaws" % "aws-java-sdk-secretsmanager" % awsSecretsVersion + val `aws-secrets-manager` = + "com.amazonaws" % "aws-java-sdk-secretsmanager" % awsSecretsVersion - val `mockito` = "org.mockito" %% "mockito-scala" % mockitoVersion + val `mockito` = "org.scalatestplus" %% "mockito-4-6" % mockitoVersion val `scalatest` = "org.scalatest" %% "scalatest" % scalaTestVersion val `jetty` = "org.eclipse.jetty" % "jetty-server" % jettyVersion val `commons-io` = "org.apache.commons" % "commons-io" % commonsIOVersion val `flexmark` = "com.vladsch.flexmark" % "flexmark-all" % flexmarkVersion - val `slf4j-api` = "org.slf4j" % "slf4j-api" % slf4jVersion - val `slf4j-simple` = "org.slf4j" % "slf4j-simple" % slf4jVersion + val `slf4j-api` = "org.slf4j" % "slf4j-api" % slf4jVersion + val `slf4j-simple` = "org.slf4j" % "slf4j-simple" % slf4jVersion val `byteBuddy` = "net.bytebuddy" % "byte-buddy" % byteBuddyVersion - val `scalaCollectionCompat` = "org.scala-lang.modules" %% "scala-collection-compat" % scalaCollectionCompatVersion + val `scalaCollectionCompat` = + "org.scala-lang.modules" %% "scala-collection-compat" % scalaCollectionCompatVersion + val `jakartaServlet` = + "jakarta.servlet" % "jakarta.servlet-api" % jakartaServletVersion } import Dependencies._ val secretProviderDeps = Seq( - `scala-logging`, - `kafka-connect-api` % Provided, - `vault-java-driver`, - `azure-key-vault`, - `azure-identity` exclude("javax.activation", "activation"), - `aws-secrets-manager`, - `scalaCollectionCompat`, - `mockito` % Test, - `byteBuddy` % Test, - `scalatest` % Test, - `jetty` % Test, - `commons-io` % Test, - `flexmark` % Test, - `slf4j-api` % Test, - `slf4j-simple` % Test, + `scala-logging`, + `kafka-connect-api` % Provided, + `vault-java-driver`, + `azure-key-vault`, + `azure-identity` exclude ("javax.activation", "activation"), + `aws-secrets-manager`, + `scalaCollectionCompat`, + `jakartaServlet` % Test, + `mockito` % Test, + `byteBuddy` % Test, + `scalatest` % Test, + `jetty` % Test, + `commons-io` % Test, + `flexmark` % Test, + `slf4j-api` % Test, + `slf4j-simple` % Test ) } diff --git a/project/Settings.scala b/project/Settings.scala index 5723ecf..6a7edd4 100644 --- a/project/Settings.scala +++ b/project/Settings.scala @@ -10,13 +10,14 @@ import sbtassembly.MergeStrategy object Settings extends Dependencies { val scala212 = "2.12.14" - val scala213 = "2.13.6" + val scala213 = "2.13.10" + val scala3 = "3.2.2" val nextVersion = "2.1.7" val artifactVersion = { sys.env.get("LENSES_TAG_NAME") match { case Some(tag) => tag - case _ => s"$nextVersion-SNAPSHOT" + case _ => s"$nextVersion-SNAPSHOT" } } @@ -36,15 +37,15 @@ object Settings extends Dependencies { Resolver.mavenCentral ), libraryDependencies ++= secretProviderDeps, - crossScalaVersions := List(scala213, scala212), + crossScalaVersions := List( /*scala3, */ scala213 /*scala212*/ ), Compile / scalacOptions ++= Seq( - "-target:jvm-1.8", + "-release:11", "-encoding", "utf8", "-deprecation", "-unchecked", "-feature", - "8" + "11" ), Compile / scalacOptions ++= { CrossVersion.partialVersion(scalaVersion.value) match { diff --git a/project/Versioning.scala b/project/Versioning.scala index e874a0c..bcfc176 100644 --- a/project/Versioning.scala +++ b/project/Versioning.scala @@ -1,9 +1,9 @@ import java.util.regex.{Matcher, Pattern} - case class SemanticVersioning(version: String) { - private val versionPattern: Pattern = Pattern.compile("([1-9]\\d*)\\.(\\d+)\\.(\\d+)(?:-([a-zA-Z0-9]+))?") + private val versionPattern: Pattern = + Pattern.compile("([1-9]\\d*)\\.(\\d+)\\.(\\d+)(?:-([a-zA-Z0-9]+))?") private val matcher: Matcher = versionPattern.matcher(version) def majorMinor = { diff --git a/project/build.properties b/project/build.properties index bb5389d..875272d 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.5.5 \ No newline at end of file +sbt.version=1.8.2 \ No newline at end of file diff --git a/project/plugins.sbt b/project/plugins.sbt index 72477a2..a2af00e 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1 +1,3 @@ addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.15.0") + +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.0") diff --git a/src/main/scala/io/lenses/connect/secrets/async/AsyncFunctionLoop.scala b/src/main/scala/io/lenses/connect/secrets/async/AsyncFunctionLoop.scala index 82640c4..e7575f5 100644 --- a/src/main/scala/io/lenses/connect/secrets/async/AsyncFunctionLoop.scala +++ b/src/main/scala/io/lenses/connect/secrets/async/AsyncFunctionLoop.scala @@ -13,7 +13,7 @@ import java.util.concurrent.{Executors, TimeUnit} import scala.concurrent.duration.Duration class AsyncFunctionLoop(interval: Duration, description: String)(thunk: => Unit) - extends AutoCloseable + extends AutoCloseable with StrictLogging { private val running = new AtomicBoolean(false) @@ -25,7 +25,9 @@ class AsyncFunctionLoop(interval: Duration, description: String)(thunk: => Unit) if (!running.compareAndSet(false, true)) { throw new IllegalStateException(s"$description already running.") } - logger.info(s"Starting $description loop with an interval of ${interval.toMillis}ms.") + logger.info( + s"Starting $description loop with an interval of ${interval.toMillis}ms." + ) executorService.submit(new Runnable { override def run(): Unit = { while (running.get()) { @@ -33,8 +35,7 @@ class AsyncFunctionLoop(interval: Duration, description: String)(thunk: => Unit) Thread.sleep(interval.toMillis) thunk success.incrementAndGet() - } - catch { + } catch { case _: InterruptedException => case t: Throwable => logger.warn("Failed to renew the Kerberos ticket", t) diff --git a/src/main/scala/io/lenses/connect/secrets/config/AWSProviderConfig.scala b/src/main/scala/io/lenses/connect/secrets/config/AWSProviderConfig.scala index 89ad183..544368e 100644 --- a/src/main/scala/io/lenses/connect/secrets/config/AWSProviderConfig.scala +++ b/src/main/scala/io/lenses/connect/secrets/config/AWSProviderConfig.scala @@ -1,64 +1,64 @@ -/* - * - * * Copyright 2017-2020 Lenses.io Ltd - * - */ - -package io.lenses.connect.secrets.config - -import io.lenses.connect.secrets.connect.{AuthMode, _} -import org.apache.kafka.common.config.ConfigDef.{Importance, Type} -import org.apache.kafka.common.config.{AbstractConfig, ConfigDef} - -import java.util - -object AWSProviderConfig { - - val AWS_REGION: String = "aws.region" - val AWS_ACCESS_KEY: String = "aws.access.key" - val AWS_SECRET_KEY: String = "aws.secret.key" - val AUTH_METHOD: String = "aws.auth.method" - - val config: ConfigDef = new ConfigDef() - .define( - AWS_REGION, - Type.STRING, - Importance.HIGH, - "AWS region the Secrets manager is in" - ) - .define( - AWS_ACCESS_KEY, - Type.STRING, - null, - Importance.HIGH, - "AWS access key" - ) - .define( - AWS_SECRET_KEY, - Type.PASSWORD, - null, - Importance.HIGH, - "AWS password key" - ) - .define( - AUTH_METHOD, - Type.STRING, - AuthMode.CREDENTIALS.toString, - Importance.HIGH, - """ - | AWS authenticate method, 'credentials' to use the provided credentials - | or 'default' for the standard AWS provider chain. - | Default is 'credentials' - |""".stripMargin - ) - .define( - FILE_DIR, - Type.STRING, - "", - Importance.MEDIUM, - FILE_DIR_DESC - ) -} - -case class AWSProviderConfig(props: util.Map[String, _]) - extends AbstractConfig(AWSProviderConfig.config, props) +/* + * + * * Copyright 2017-2020 Lenses.io Ltd + * + */ + +package io.lenses.connect.secrets.config + +import io.lenses.connect.secrets.connect.{AuthMode, _} +import org.apache.kafka.common.config.ConfigDef.{Importance, Type} +import org.apache.kafka.common.config.{AbstractConfig, ConfigDef} + +import java.util + +object AWSProviderConfig { + + val AWS_REGION: String = "aws.region" + val AWS_ACCESS_KEY: String = "aws.access.key" + val AWS_SECRET_KEY: String = "aws.secret.key" + val AUTH_METHOD: String = "aws.auth.method" + + val config: ConfigDef = new ConfigDef() + .define( + AWS_REGION, + Type.STRING, + Importance.HIGH, + "AWS region the Secrets manager is in" + ) + .define( + AWS_ACCESS_KEY, + Type.STRING, + null, + Importance.HIGH, + "AWS access key" + ) + .define( + AWS_SECRET_KEY, + Type.PASSWORD, + null, + Importance.HIGH, + "AWS password key" + ) + .define( + AUTH_METHOD, + Type.STRING, + AuthMode.CREDENTIALS.toString, + Importance.HIGH, + """ + | AWS authenticate method, 'credentials' to use the provided credentials + | or 'default' for the standard AWS provider chain. + | Default is 'credentials' + |""".stripMargin + ) + .define( + FILE_DIR, + Type.STRING, + "", + Importance.MEDIUM, + FILE_DIR_DESC + ) +} + +case class AWSProviderConfig(props: util.Map[String, _]) + extends AbstractConfig(AWSProviderConfig.config, props) diff --git a/src/main/scala/io/lenses/connect/secrets/config/AWSProviderSettings.scala b/src/main/scala/io/lenses/connect/secrets/config/AWSProviderSettings.scala index 83fb0b9..d11b096 100644 --- a/src/main/scala/io/lenses/connect/secrets/config/AWSProviderSettings.scala +++ b/src/main/scala/io/lenses/connect/secrets/config/AWSProviderSettings.scala @@ -1,54 +1,54 @@ -/* - * - * * Copyright 2017-2020 Lenses.io Ltd - * - */ - -package io.lenses.connect.secrets.config - -import io.lenses.connect.secrets.connect.AuthMode.AuthMode -import io.lenses.connect.secrets.connect._ -import org.apache.kafka.common.config.types.Password -import org.apache.kafka.connect.errors.ConnectException - -case class AWSProviderSettings( - region: String, - accessKey: String, - secretKey: Password, - authMode: AuthMode, - fileDir: String -) - -import io.lenses.connect.secrets.config.AbstractConfigExtensions._ -object AWSProviderSettings { - def apply(configs: AWSProviderConfig): AWSProviderSettings = { - val region = configs.getStringOrThrowOnNull(AWSProviderConfig.AWS_REGION) - val accessKey = - configs.getStringOrThrowOnNull(AWSProviderConfig.AWS_ACCESS_KEY) - val secretKey = - configs.getPasswordOrThrowOnNull(AWSProviderConfig.AWS_SECRET_KEY) - - val authMode = - getAuthenticationMethod(configs.getString(AWSProviderConfig.AUTH_METHOD)) - - if (authMode == AuthMode.CREDENTIALS) { - if (accessKey.isEmpty) - throw new ConnectException( - s"${AWSProviderConfig.AWS_ACCESS_KEY} not set" - ) - if (secretKey.value().isEmpty) - throw new ConnectException( - s"${AWSProviderConfig.AWS_SECRET_KEY} not set" - ) - } - val fileDir = configs.getString(FILE_DIR) - - new AWSProviderSettings( - region = region, - accessKey = accessKey, - secretKey = secretKey, - authMode = authMode, - fileDir = fileDir - ) - } -} +/* + * + * * Copyright 2017-2020 Lenses.io Ltd + * + */ + +package io.lenses.connect.secrets.config + +import io.lenses.connect.secrets.connect.AuthMode.AuthMode +import io.lenses.connect.secrets.connect._ +import org.apache.kafka.common.config.types.Password +import org.apache.kafka.connect.errors.ConnectException + +case class AWSProviderSettings( + region: String, + accessKey: String, + secretKey: Password, + authMode: AuthMode, + fileDir: String +) + +import io.lenses.connect.secrets.config.AbstractConfigExtensions._ +object AWSProviderSettings { + def apply(configs: AWSProviderConfig): AWSProviderSettings = { + val region = configs.getStringOrThrowOnNull(AWSProviderConfig.AWS_REGION) + val accessKey = + configs.getStringOrThrowOnNull(AWSProviderConfig.AWS_ACCESS_KEY) + val secretKey = + configs.getPasswordOrThrowOnNull(AWSProviderConfig.AWS_SECRET_KEY) + + val authMode = + getAuthenticationMethod(configs.getString(AWSProviderConfig.AUTH_METHOD)) + + if (authMode == AuthMode.CREDENTIALS) { + if (accessKey.isEmpty) + throw new ConnectException( + s"${AWSProviderConfig.AWS_ACCESS_KEY} not set" + ) + if (secretKey.value().isEmpty) + throw new ConnectException( + s"${AWSProviderConfig.AWS_SECRET_KEY} not set" + ) + } + val fileDir = configs.getString(FILE_DIR) + + new AWSProviderSettings( + region = region, + accessKey = accessKey, + secretKey = secretKey, + authMode = authMode, + fileDir = fileDir + ) + } +} diff --git a/src/main/scala/io/lenses/connect/secrets/config/AbstractConfigExtensions.scala b/src/main/scala/io/lenses/connect/secrets/config/AbstractConfigExtensions.scala index bc8933b..5cebe46 100644 --- a/src/main/scala/io/lenses/connect/secrets/config/AbstractConfigExtensions.scala +++ b/src/main/scala/io/lenses/connect/secrets/config/AbstractConfigExtensions.scala @@ -1,29 +1,29 @@ -/* - * - * * Copyright 2017-2020 Lenses.io Ltd - * - */ - -package io.lenses.connect.secrets.config - -import org.apache.kafka.common.config.AbstractConfig -import org.apache.kafka.common.config.types.Password -import org.apache.kafka.connect.errors.ConnectException - -object AbstractConfigExtensions { - implicit class AbstractConfigExtension(val config: AbstractConfig) - extends AnyVal { - def getStringOrThrowOnNull(field: String): String = - Option(config.getString(field)).getOrElse(raiseException(field)) - - def getPasswordOrThrowOnNull(field: String): Password = { - val password = config.getPassword(field) - if (password == null) raiseException(field) - password - } - - private def raiseException(fieldName: String) = throw new ConnectException( - s"$fieldName not set" - ) - } -} +/* + * + * * Copyright 2017-2020 Lenses.io Ltd + * + */ + +package io.lenses.connect.secrets.config + +import org.apache.kafka.common.config.AbstractConfig +import org.apache.kafka.common.config.types.Password +import org.apache.kafka.connect.errors.ConnectException + +object AbstractConfigExtensions { + implicit class AbstractConfigExtension(val config: AbstractConfig) + extends AnyVal { + def getStringOrThrowOnNull(field: String): String = + Option(config.getString(field)).getOrElse(raiseException(field)) + + def getPasswordOrThrowOnNull(field: String): Password = { + val password = config.getPassword(field) + if (password == null) raiseException(field) + password + } + + private def raiseException(fieldName: String) = throw new ConnectException( + s"$fieldName not set" + ) + } +} diff --git a/src/main/scala/io/lenses/connect/secrets/config/Aes256ProviderConfig.scala b/src/main/scala/io/lenses/connect/secrets/config/Aes256ProviderConfig.scala index 1f3dd81..8c164e1 100644 --- a/src/main/scala/io/lenses/connect/secrets/config/Aes256ProviderConfig.scala +++ b/src/main/scala/io/lenses/connect/secrets/config/Aes256ProviderConfig.scala @@ -8,7 +8,7 @@ import java.util object Aes256ProviderConfig { val SECRET_KEY = "aes256.key" - + val config = new ConfigDef() .define( SECRET_KEY, @@ -27,7 +27,7 @@ object Aes256ProviderConfig { } case class Aes256ProviderConfig(props: util.Map[String, _]) - extends AbstractConfig(Aes256ProviderConfig.config, props) { - def aes256Key: String = getString(Aes256ProviderConfig.SECRET_KEY) - def writeDirectory: String = getString(FILE_DIR) - } + extends AbstractConfig(Aes256ProviderConfig.config, props) { + def aes256Key: String = getString(Aes256ProviderConfig.SECRET_KEY) + def writeDirectory: String = getString(FILE_DIR) +} diff --git a/src/main/scala/io/lenses/connect/secrets/config/AzureProviderConfig.scala b/src/main/scala/io/lenses/connect/secrets/config/AzureProviderConfig.scala index dab074e..a8b5c5e 100644 --- a/src/main/scala/io/lenses/connect/secrets/config/AzureProviderConfig.scala +++ b/src/main/scala/io/lenses/connect/secrets/config/AzureProviderConfig.scala @@ -1,65 +1,65 @@ -/* - * - * * Copyright 2017-2020 Lenses.io Ltd - * - */ - -package io.lenses.connect.secrets.config - -import io.lenses.connect.secrets.connect._ -import org.apache.kafka.common.config.ConfigDef.{Importance, Type} -import org.apache.kafka.common.config.{AbstractConfig, ConfigDef} - -import java.util - -object AzureProviderConfig { - - val AZURE_CLIENT_ID = "azure.client.id" - val AZURE_TENANT_ID = "azure.tenant.id" - val AZURE_SECRET_ID = "azure.secret.id" - val AUTH_METHOD = "azure.auth.method" - - val config: ConfigDef = new ConfigDef() - .define( - AZURE_CLIENT_ID, - Type.STRING, - null, - Importance.HIGH, - "Azure client id for the service principal" - ) - .define( - AZURE_TENANT_ID, - Type.STRING, - null, - Importance.HIGH, - "Azure tenant id for the service principal" - ) - .define( - AZURE_SECRET_ID, - Type.PASSWORD, - null, - Importance.HIGH, - "Azure secret id for the service principal" - ) - .define( - AUTH_METHOD, - Type.STRING, - AuthMode.CREDENTIALS.toString, - Importance.MEDIUM, - """ - |Azure authenticate method, 'credentials' to use the provided credentials or - |'default' for the standard Azure provider chain - |Default is 'credentials' - |""".stripMargin - ) - .define( - FILE_DIR, - Type.STRING, - "", - Importance.MEDIUM, - FILE_DIR_DESC - ) -} - -case class AzureProviderConfig(props: util.Map[String, _]) - extends AbstractConfig(AzureProviderConfig.config, props) +/* + * + * * Copyright 2017-2020 Lenses.io Ltd + * + */ + +package io.lenses.connect.secrets.config + +import io.lenses.connect.secrets.connect._ +import org.apache.kafka.common.config.ConfigDef.{Importance, Type} +import org.apache.kafka.common.config.{AbstractConfig, ConfigDef} + +import java.util + +object AzureProviderConfig { + + val AZURE_CLIENT_ID = "azure.client.id" + val AZURE_TENANT_ID = "azure.tenant.id" + val AZURE_SECRET_ID = "azure.secret.id" + val AUTH_METHOD = "azure.auth.method" + + val config: ConfigDef = new ConfigDef() + .define( + AZURE_CLIENT_ID, + Type.STRING, + null, + Importance.HIGH, + "Azure client id for the service principal" + ) + .define( + AZURE_TENANT_ID, + Type.STRING, + null, + Importance.HIGH, + "Azure tenant id for the service principal" + ) + .define( + AZURE_SECRET_ID, + Type.PASSWORD, + null, + Importance.HIGH, + "Azure secret id for the service principal" + ) + .define( + AUTH_METHOD, + Type.STRING, + AuthMode.CREDENTIALS.toString, + Importance.MEDIUM, + """ + |Azure authenticate method, 'credentials' to use the provided credentials or + |'default' for the standard Azure provider chain + |Default is 'credentials' + |""".stripMargin + ) + .define( + FILE_DIR, + Type.STRING, + "", + Importance.MEDIUM, + FILE_DIR_DESC + ) +} + +case class AzureProviderConfig(props: util.Map[String, _]) + extends AbstractConfig(AzureProviderConfig.config, props) diff --git a/src/main/scala/io/lenses/connect/secrets/config/AzureProviderSettings.scala b/src/main/scala/io/lenses/connect/secrets/config/AzureProviderSettings.scala index b0d8fcf..31d58ce 100644 --- a/src/main/scala/io/lenses/connect/secrets/config/AzureProviderSettings.scala +++ b/src/main/scala/io/lenses/connect/secrets/config/AzureProviderSettings.scala @@ -1,63 +1,63 @@ -/* - * - * * Copyright 2017-2020 Lenses.io Ltd - * - */ - -package io.lenses.connect.secrets.config - -import com.typesafe.scalalogging.StrictLogging -import io.lenses.connect.secrets.connect.AuthMode.AuthMode -import io.lenses.connect.secrets.connect._ -import org.apache.kafka.common.config.types.Password -import org.apache.kafka.connect.errors.ConnectException - -case class AzureProviderSettings( - clientId: String, - tenantId: String, - secretId: Password, - authMode: AuthMode, - fileDir: String -) - -import io.lenses.connect.secrets.config.AbstractConfigExtensions._ -object AzureProviderSettings extends StrictLogging { - def apply(config: AzureProviderConfig): AzureProviderSettings = { - - val authMode = getAuthenticationMethod( - config.getString(AzureProviderConfig.AUTH_METHOD) - ) - - if (authMode == AuthMode.CREDENTIALS) { - val clientId = - config.getStringOrThrowOnNull(AzureProviderConfig.AZURE_CLIENT_ID) - val tenantId = - config.getStringOrThrowOnNull(AzureProviderConfig.AZURE_TENANT_ID) - val secretId = - config.getPasswordOrThrowOnNull(AzureProviderConfig.AZURE_SECRET_ID) - - if (clientId.isEmpty) - throw new ConnectException( - s"${AzureProviderConfig.AZURE_CLIENT_ID} not set" - ) - if (tenantId.isEmpty) - throw new ConnectException( - s"${AzureProviderConfig.AZURE_TENANT_ID} not set" - ) - if (secretId.value().isEmpty) - throw new ConnectException( - s"${AzureProviderConfig.AZURE_SECRET_ID} not set" - ) - } - - val fileDir = config.getString(FILE_DIR) - - AzureProviderSettings( - clientId = config.getString(AzureProviderConfig.AZURE_CLIENT_ID), - tenantId = config.getString(AzureProviderConfig.AZURE_TENANT_ID), - secretId = config.getPassword(AzureProviderConfig.AZURE_SECRET_ID), - authMode = authMode, - fileDir = fileDir - ) - } -} +/* + * + * * Copyright 2017-2020 Lenses.io Ltd + * + */ + +package io.lenses.connect.secrets.config + +import com.typesafe.scalalogging.StrictLogging +import io.lenses.connect.secrets.connect.AuthMode.AuthMode +import io.lenses.connect.secrets.connect._ +import org.apache.kafka.common.config.types.Password +import org.apache.kafka.connect.errors.ConnectException + +case class AzureProviderSettings( + clientId: String, + tenantId: String, + secretId: Password, + authMode: AuthMode, + fileDir: String +) + +import io.lenses.connect.secrets.config.AbstractConfigExtensions._ +object AzureProviderSettings extends StrictLogging { + def apply(config: AzureProviderConfig): AzureProviderSettings = { + + val authMode = getAuthenticationMethod( + config.getString(AzureProviderConfig.AUTH_METHOD) + ) + + if (authMode == AuthMode.CREDENTIALS) { + val clientId = + config.getStringOrThrowOnNull(AzureProviderConfig.AZURE_CLIENT_ID) + val tenantId = + config.getStringOrThrowOnNull(AzureProviderConfig.AZURE_TENANT_ID) + val secretId = + config.getPasswordOrThrowOnNull(AzureProviderConfig.AZURE_SECRET_ID) + + if (clientId.isEmpty) + throw new ConnectException( + s"${AzureProviderConfig.AZURE_CLIENT_ID} not set" + ) + if (tenantId.isEmpty) + throw new ConnectException( + s"${AzureProviderConfig.AZURE_TENANT_ID} not set" + ) + if (secretId.value().isEmpty) + throw new ConnectException( + s"${AzureProviderConfig.AZURE_SECRET_ID} not set" + ) + } + + val fileDir = config.getString(FILE_DIR) + + AzureProviderSettings( + clientId = config.getString(AzureProviderConfig.AZURE_CLIENT_ID), + tenantId = config.getString(AzureProviderConfig.AZURE_TENANT_ID), + secretId = config.getPassword(AzureProviderConfig.AZURE_SECRET_ID), + authMode = authMode, + fileDir = fileDir + ) + } +} diff --git a/src/main/scala/io/lenses/connect/secrets/config/ENVProviderConfig.scala b/src/main/scala/io/lenses/connect/secrets/config/ENVProviderConfig.scala index f6aecae..e21f531 100644 --- a/src/main/scala/io/lenses/connect/secrets/config/ENVProviderConfig.scala +++ b/src/main/scala/io/lenses/connect/secrets/config/ENVProviderConfig.scala @@ -1,26 +1,26 @@ -/* - * - * * Copyright 2017-2020 Lenses.io Ltd - * - */ - -package io.lenses.connect.secrets.config - -import io.lenses.connect.secrets.connect.{FILE_DIR, FILE_DIR_DESC} -import org.apache.kafka.common.config.ConfigDef.{Importance, Type} -import org.apache.kafka.common.config.{AbstractConfig, ConfigDef} - -import java.util - -object ENVProviderConfig { - val config = new ConfigDef().define( - FILE_DIR, - Type.STRING, - "", - Importance.MEDIUM, - FILE_DIR_DESC - ) -} - -case class ENVProviderConfig(props: util.Map[String, _]) - extends AbstractConfig(ENVProviderConfig.config, props) +/* + * + * * Copyright 2017-2020 Lenses.io Ltd + * + */ + +package io.lenses.connect.secrets.config + +import io.lenses.connect.secrets.connect.{FILE_DIR, FILE_DIR_DESC} +import org.apache.kafka.common.config.ConfigDef.{Importance, Type} +import org.apache.kafka.common.config.{AbstractConfig, ConfigDef} + +import java.util + +object ENVProviderConfig { + val config = new ConfigDef().define( + FILE_DIR, + Type.STRING, + "", + Importance.MEDIUM, + FILE_DIR_DESC + ) +} + +case class ENVProviderConfig(props: util.Map[String, _]) + extends AbstractConfig(ENVProviderConfig.config, props) diff --git a/src/main/scala/io/lenses/connect/secrets/config/VaultProviderConfig.scala b/src/main/scala/io/lenses/connect/secrets/config/VaultProviderConfig.scala index dc427aa..05eb808 100644 --- a/src/main/scala/io/lenses/connect/secrets/config/VaultProviderConfig.scala +++ b/src/main/scala/io/lenses/connect/secrets/config/VaultProviderConfig.scala @@ -1,362 +1,363 @@ -/* - * - * * Copyright 2017-2020 Lenses.io Ltd - * - */ - -package io.lenses.connect.secrets.config - -import io.lenses.connect.secrets.connect.{FILE_DIR, FILE_DIR_DESC} -import org.apache.kafka.common.config.ConfigDef.{Importance, Type} -import org.apache.kafka.common.config.{AbstractConfig, ConfigDef, SslConfigs} - -import java.util - -object VaultAuthMethod extends Enumeration { - type VaultAuthMethod = Value - val KUBERNETES, AWSIAM, GCP, USERPASS, LDAP, JWT, CERT, APPROLE, TOKEN, - GITHUB = Value - - def withNameOpt(s: String): Option[Value] = values.find(_.toString == s) -} - -object VaultProviderConfig { - val VAULT_ADDR: String = "vault.addr" - val VAULT_TOKEN: String = "vault.token" - val VAULT_NAMESPACE: String = "vault.namespace" - val VAULT_CLIENT_PEM: String = "vault.client.pem" - val VAULT_PEM: String = "vault.pem" - val VAULT_ENGINE_VERSION = "vault.engine.version" - val AUTH_METHOD: String = "vault.auth.method" - - val VAULT_TRUSTSTORE_LOC: String = - s"vault.${SslConfigs.SSL_TRUSTSTORE_LOCATION_CONFIG}" - val VAULT_KEYSTORE_LOC: String = - s"vault.${SslConfigs.SSL_KEYSTORE_LOCATION_CONFIG}" - val VAULT_KEYSTORE_PASS: String = - s"vault.${SslConfigs.SSL_KEYSTORE_PASSWORD_CONFIG}" - - val KUBERNETES_ROLE: String = "kubernetes.role" - val KUBERNETES_TOKEN_PATH: String = "kubernetes.token.path" - val KUBERNETES_DEFAULT_TOKEN_PATH: String = - "/var/run/secrets/kubernetes.io/serviceaccount/token" - - val APP_ROLE: String = "app.role.id" - val APP_ROLE_SECRET_ID: String = "app.role.secret.id" - - val AWS_ROLE: String = "aws.role" - val AWS_REQUEST_URL: String = "aws.request.url" - val AWS_REQUEST_HEADERS: String = "aws.request.headers" - val AWS_REQUEST_BODY: String = "aws.request.body" - val AWS_MOUNT: String = "aws.mount" - - val GCP_ROLE: String = "gcp.role" - val GCP_JWT: String = "gcp.jwt" - - val LDAP_USERNAME: String = "ldap.username" - val LDAP_PASSWORD: String = "ldap.password" - val LDAP_MOUNT: String = "ldap.mount" - - val USERNAME: String = "username" - val PASSWORD: String = "password" - val UP_MOUNT: String = "mount" - - val JWT_ROLE: String = "jwt.role" - val JWT_PROVIDER: String = "jwt.provider" - val JWT: String = "jwt" - - val CERT_MOUNT: String = "cert.mount" - - val GITHUB_TOKEN: String = "github.token" - val GITHUB_MOUNT: String = "github.mount" - - val TOKEN_RENEWAL: String = "token.renewal.ms" - val TOKEN_RENEWAL_DEFAULT: Int = 600000 - - val config: ConfigDef = new ConfigDef() - .define( - VAULT_ADDR, - ConfigDef.Type.STRING, - "http://localhost:8200", - Importance.HIGH, - "Address of the Vault server" - ) - .define( - VAULT_TOKEN, - ConfigDef.Type.PASSWORD, - null, - Importance.HIGH, - s"Vault app role token. $AUTH_METHOD must be 'token'" - ) - .define( - VAULT_NAMESPACE, - Type.STRING, - "", - Importance.MEDIUM, - "Sets a global namespace to the Vault server instance. Required Vault Enterprize Pro" - ) - .define( - VAULT_KEYSTORE_LOC, - ConfigDef.Type.STRING, - "", - ConfigDef.Importance.HIGH, - SslConfigs.SSL_KEYSTORE_LOCATION_DOC - ) - .define( - VAULT_KEYSTORE_PASS, - ConfigDef.Type.PASSWORD, - "", - ConfigDef.Importance.HIGH, - SslConfigs.SSL_KEYSTORE_PASSWORD_DOC - ) - .define( - VAULT_TRUSTSTORE_LOC, - ConfigDef.Type.STRING, - "", - ConfigDef.Importance.HIGH, - SslConfigs.SSL_TRUSTSTORE_LOCATION_DOC - ) - .define( - VAULT_PEM, - Type.STRING, - "", - Importance.HIGH, - "File containing the Vault server certificate string contents" - ) - .define( - VAULT_CLIENT_PEM, - Type.STRING, - "", - Importance.HIGH, - "File containing the Client certificate string contents" - ) - .define( - VAULT_ENGINE_VERSION, - Type.INT, - 2, - Importance.HIGH, - "KV Secrets Engine version of the Vault server instance. Defaults to 2" - ) - // auth mode - .define( - AUTH_METHOD, - Type.STRING, - "token", - Importance.HIGH, - """ - |The authentication mode for Vault. - |Available values are approle, userpass, kubernetes, cert, token, ldap, gcp, awsiam, jwt, github - | - |""".stripMargin - ) - // app role auth mode - .define( - APP_ROLE, - Type.STRING, - null, - Importance.HIGH, - s"Vault App role id. $AUTH_METHOD must be 'approle'" - ) - .define( - APP_ROLE_SECRET_ID, - Type.PASSWORD, - null, - Importance.HIGH, - s"Vault App role name secret id. $AUTH_METHOD must be 'approle'" - ) - // userpass - .define( - USERNAME, - Type.STRING, - null, - Importance.HIGH, - s"Username to connect to Vault with. $AUTH_METHOD must be 'userpass'" - ) - .define( - PASSWORD, - Type.PASSWORD, - null, - Importance.HIGH, - s"Password for the username. $AUTH_METHOD must be 'uerspass'" - ) - .define( - UP_MOUNT, - Type.STRING, - "userpass", - Importance.HIGH, - s"The mount name of the userpass authentication back end. Defaults to 'userpass'. $AUTH_METHOD must be 'userpass'" - ) - // kubernetes - .define( - KUBERNETES_ROLE, - Type.STRING, - null, - Importance.HIGH, - s"The kubernetes role used for authentication. $AUTH_METHOD must be 'kubernetes'" - ) - .define( - KUBERNETES_TOKEN_PATH, - Type.STRING, - KUBERNETES_DEFAULT_TOKEN_PATH, - Importance.HIGH, - s""" - | s"Path to the service account token. $AUTH_METHOD must be 'kubernetes'. - | Defaults to $KUBERNETES_DEFAULT_TOKEN_PATH - - | $AUTH_METHOD must be ' - - |""".stripMargin - ) - // awsiam - .define( - AWS_ROLE, - Type.STRING, - null, - Importance.HIGH, - s""" - |Name of the role against which the login is being attempted. If role is not specified, t - in endpoint - |looks for a role bearing the name of the AMI ID of the EC2 instance that is trying to - ing the ec2 - |auth method, or the "friendly name" (i.e., role name or username) of the IAM pr - henticated. - |If a matching role is not found, login fails. $AUTH_METHOD - must be 'awsiam' - |""".stripMargin - ) - .define( - AWS_REQUEST_URL, - Type.STRING, - null, - Importance.HIGH, - s""" - |PKCS7 signature of the identity document with all \n characters removed.Base64-encoded Hsed in the signed request. - |Most likely just aHR0cHM6Ly9zdHMuYW1hem9uYXdzLmNvbS8= (base64-encoding of https://sts.amom/) as most requests will - |probably use POST with an empty URI. $AUTH_METHOD must be 'awsiam' - |""".stripMargin - ) - .define( - AWS_REQUEST_HEADERS, - Type.PASSWORD, - null, - Importance.HIGH, - s"Request headers. $AUTH_METHOD must be 'awsiam'" - ) - .define( - AWS_REQUEST_BODY, - Type.PASSWORD, - null, - Importance.HIGH, - s""" - - ded body of the signed request. - |Most likely QWN0aW9uPUdldENhbGxlcklkZ - nNpb249MjAxMS0wNi0xNQ== which is - |the base64 encoding of Action=GetCallerIdentity&Versi - 5. $AUTH_METHOD must be 'awsiam' - |""".stripMargin - ) - .define( - AWS_MOUNT, - Type.STRING, - "aws", - Importance.HIGH, - s"AWS auth mount. $AUTH_METHOD must be 'awsiam'. Default 'aws'" - ) - //ldap - .define( - LDAP_USERNAME, - Type.STRING, - null, - Importance.HIGH, - s"LDAP username to connect to Vault with. $AUTH_METHOD must be 'ldap'" - ) - .define( - LDAP_PASSWORD, - Type.PASSWORD, - null, - Importance.HIGH, - s"LDAP Password for the username. $AUTH_METHOD must be 'ldap'" - ) - .define( - LDAP_MOUNT, - Type.STRING, - "ldap", - Importance.HIGH, - s"The mount name of the ldap authentication back end. Defaults to 'ldap'. $AUTH_METHOD must be 'ldap'" - ) - //jwt - .define( - JWT_ROLE, - Type.STRING, - null, - Importance.HIGH, - s"Role the JWT token belongs to. $AUTH_METHOD must be 'jwt'" - ) - .define( - JWT_PROVIDER, - Type.STRING, - null, - Importance.HIGH, - s"Provider of JWT token. $AUTH_METHOD must be 'jwt'" - ) - .define( - JWT, - Type.PASSWORD, - null, - Importance.HIGH, - s"JWT token. $AUTH_METHOD must be 'jwt'" - ) - //gcp - .define( - GCP_ROLE, - Type.STRING, - null, - Importance.HIGH, - s"The gcp role used for authentication. $AUTH_METHOD must be 'gcp'" - ) - .define( - GCP_JWT, - Type.PASSWORD, - null, - Importance.HIGH, - s"JWT token. $AUTH_METHOD must be 'gcp'" - ) - // cert mount - .define( - CERT_MOUNT, - Type.STRING, - "cert", - Importance.HIGH, - s"The mount name of the cert authentication back end. Defaults to 'cert'. $AUTH_METHOD must be 'cert'" - ) - .define( - GITHUB_TOKEN, - Type.PASSWORD, - null, - Importance.HIGH, - s"The github app-id used for authentication. $AUTH_METHOD must be 'github'" - ) - .define( - GITHUB_MOUNT, - Type.STRING, - "github", - Importance.HIGH, - s"The mount name of the github authentication back end. Defaults to 'cert'. $AUTH_METHOD must be 'github'" - ) - .define( - FILE_DIR, - Type.STRING, - "", - Importance.MEDIUM, - FILE_DIR_DESC - ).define( - TOKEN_RENEWAL, - Type.INT, - TOKEN_RENEWAL_DEFAULT, - Importance.MEDIUM, - "The time in milliseconds to renew the Vault token" - ) -} -case class VaultProviderConfig(props: util.Map[String, _]) - extends AbstractConfig(VaultProviderConfig.config, props) +/* + * + * * Copyright 2017-2020 Lenses.io Ltd + * + */ + +package io.lenses.connect.secrets.config + +import io.lenses.connect.secrets.connect.{FILE_DIR, FILE_DIR_DESC} +import org.apache.kafka.common.config.ConfigDef.{Importance, Type} +import org.apache.kafka.common.config.{AbstractConfig, ConfigDef, SslConfigs} + +import java.util + +object VaultAuthMethod extends Enumeration { + type VaultAuthMethod = Value + val KUBERNETES, AWSIAM, GCP, USERPASS, LDAP, JWT, CERT, APPROLE, TOKEN, + GITHUB = Value + + def withNameOpt(s: String): Option[Value] = values.find(_.toString == s) +} + +object VaultProviderConfig { + val VAULT_ADDR: String = "vault.addr" + val VAULT_TOKEN: String = "vault.token" + val VAULT_NAMESPACE: String = "vault.namespace" + val VAULT_CLIENT_PEM: String = "vault.client.pem" + val VAULT_PEM: String = "vault.pem" + val VAULT_ENGINE_VERSION = "vault.engine.version" + val AUTH_METHOD: String = "vault.auth.method" + + val VAULT_TRUSTSTORE_LOC: String = + s"vault.${SslConfigs.SSL_TRUSTSTORE_LOCATION_CONFIG}" + val VAULT_KEYSTORE_LOC: String = + s"vault.${SslConfigs.SSL_KEYSTORE_LOCATION_CONFIG}" + val VAULT_KEYSTORE_PASS: String = + s"vault.${SslConfigs.SSL_KEYSTORE_PASSWORD_CONFIG}" + + val KUBERNETES_ROLE: String = "kubernetes.role" + val KUBERNETES_TOKEN_PATH: String = "kubernetes.token.path" + val KUBERNETES_DEFAULT_TOKEN_PATH: String = + "/var/run/secrets/kubernetes.io/serviceaccount/token" + + val APP_ROLE: String = "app.role.id" + val APP_ROLE_SECRET_ID: String = "app.role.secret.id" + + val AWS_ROLE: String = "aws.role" + val AWS_REQUEST_URL: String = "aws.request.url" + val AWS_REQUEST_HEADERS: String = "aws.request.headers" + val AWS_REQUEST_BODY: String = "aws.request.body" + val AWS_MOUNT: String = "aws.mount" + + val GCP_ROLE: String = "gcp.role" + val GCP_JWT: String = "gcp.jwt" + + val LDAP_USERNAME: String = "ldap.username" + val LDAP_PASSWORD: String = "ldap.password" + val LDAP_MOUNT: String = "ldap.mount" + + val USERNAME: String = "username" + val PASSWORD: String = "password" + val UP_MOUNT: String = "mount" + + val JWT_ROLE: String = "jwt.role" + val JWT_PROVIDER: String = "jwt.provider" + val JWT: String = "jwt" + + val CERT_MOUNT: String = "cert.mount" + + val GITHUB_TOKEN: String = "github.token" + val GITHUB_MOUNT: String = "github.mount" + + val TOKEN_RENEWAL: String = "token.renewal.ms" + val TOKEN_RENEWAL_DEFAULT: Int = 600000 + + val config: ConfigDef = new ConfigDef() + .define( + VAULT_ADDR, + ConfigDef.Type.STRING, + "http://localhost:8200", + Importance.HIGH, + "Address of the Vault server" + ) + .define( + VAULT_TOKEN, + ConfigDef.Type.PASSWORD, + null, + Importance.HIGH, + s"Vault app role token. $AUTH_METHOD must be 'token'" + ) + .define( + VAULT_NAMESPACE, + Type.STRING, + "", + Importance.MEDIUM, + "Sets a global namespace to the Vault server instance. Required Vault Enterprize Pro" + ) + .define( + VAULT_KEYSTORE_LOC, + ConfigDef.Type.STRING, + "", + ConfigDef.Importance.HIGH, + SslConfigs.SSL_KEYSTORE_LOCATION_DOC + ) + .define( + VAULT_KEYSTORE_PASS, + ConfigDef.Type.PASSWORD, + "", + ConfigDef.Importance.HIGH, + SslConfigs.SSL_KEYSTORE_PASSWORD_DOC + ) + .define( + VAULT_TRUSTSTORE_LOC, + ConfigDef.Type.STRING, + "", + ConfigDef.Importance.HIGH, + SslConfigs.SSL_TRUSTSTORE_LOCATION_DOC + ) + .define( + VAULT_PEM, + Type.STRING, + "", + Importance.HIGH, + "File containing the Vault server certificate string contents" + ) + .define( + VAULT_CLIENT_PEM, + Type.STRING, + "", + Importance.HIGH, + "File containing the Client certificate string contents" + ) + .define( + VAULT_ENGINE_VERSION, + Type.INT, + 2, + Importance.HIGH, + "KV Secrets Engine version of the Vault server instance. Defaults to 2" + ) + // auth mode + .define( + AUTH_METHOD, + Type.STRING, + "token", + Importance.HIGH, + """ + |The authentication mode for Vault. + |Available values are approle, userpass, kubernetes, cert, token, ldap, gcp, awsiam, jwt, github + | + |""".stripMargin + ) + // app role auth mode + .define( + APP_ROLE, + Type.STRING, + null, + Importance.HIGH, + s"Vault App role id. $AUTH_METHOD must be 'approle'" + ) + .define( + APP_ROLE_SECRET_ID, + Type.PASSWORD, + null, + Importance.HIGH, + s"Vault App role name secret id. $AUTH_METHOD must be 'approle'" + ) + // userpass + .define( + USERNAME, + Type.STRING, + null, + Importance.HIGH, + s"Username to connect to Vault with. $AUTH_METHOD must be 'userpass'" + ) + .define( + PASSWORD, + Type.PASSWORD, + null, + Importance.HIGH, + s"Password for the username. $AUTH_METHOD must be 'uerspass'" + ) + .define( + UP_MOUNT, + Type.STRING, + "userpass", + Importance.HIGH, + s"The mount name of the userpass authentication back end. Defaults to 'userpass'. $AUTH_METHOD must be 'userpass'" + ) + // kubernetes + .define( + KUBERNETES_ROLE, + Type.STRING, + null, + Importance.HIGH, + s"The kubernetes role used for authentication. $AUTH_METHOD must be 'kubernetes'" + ) + .define( + KUBERNETES_TOKEN_PATH, + Type.STRING, + KUBERNETES_DEFAULT_TOKEN_PATH, + Importance.HIGH, + s""" + | s"Path to the service account token. $AUTH_METHOD must be 'kubernetes'. + | Defaults to $KUBERNETES_DEFAULT_TOKEN_PATH + + | $AUTH_METHOD must be ' + + |""".stripMargin + ) + // awsiam + .define( + AWS_ROLE, + Type.STRING, + null, + Importance.HIGH, + s""" + |Name of the role against which the login is being attempted. If role is not specified, t + in endpoint + |looks for a role bearing the name of the AMI ID of the EC2 instance that is trying to + ing the ec2 + |auth method, or the "friendly name" (i.e., role name or username) of the IAM pr + henticated. + |If a matching role is not found, login fails. $AUTH_METHOD + must be 'awsiam' + |""".stripMargin + ) + .define( + AWS_REQUEST_URL, + Type.STRING, + null, + Importance.HIGH, + s""" + |PKCS7 signature of the identity document with all \n characters removed.Base64-encoded Hsed in the signed request. + |Most likely just aHR0cHM6Ly9zdHMuYW1hem9uYXdzLmNvbS8= (base64-encoding of https://sts.amom/) as most requests will + |probably use POST with an empty URI. $AUTH_METHOD must be 'awsiam' + |""".stripMargin + ) + .define( + AWS_REQUEST_HEADERS, + Type.PASSWORD, + null, + Importance.HIGH, + s"Request headers. $AUTH_METHOD must be 'awsiam'" + ) + .define( + AWS_REQUEST_BODY, + Type.PASSWORD, + null, + Importance.HIGH, + s""" + + ded body of the signed request. + |Most likely QWN0aW9uPUdldENhbGxlcklkZ + nNpb249MjAxMS0wNi0xNQ== which is + |the base64 encoding of Action=GetCallerIdentity&Versi + 5. $AUTH_METHOD must be 'awsiam' + |""".stripMargin + ) + .define( + AWS_MOUNT, + Type.STRING, + "aws", + Importance.HIGH, + s"AWS auth mount. $AUTH_METHOD must be 'awsiam'. Default 'aws'" + ) + //ldap + .define( + LDAP_USERNAME, + Type.STRING, + null, + Importance.HIGH, + s"LDAP username to connect to Vault with. $AUTH_METHOD must be 'ldap'" + ) + .define( + LDAP_PASSWORD, + Type.PASSWORD, + null, + Importance.HIGH, + s"LDAP Password for the username. $AUTH_METHOD must be 'ldap'" + ) + .define( + LDAP_MOUNT, + Type.STRING, + "ldap", + Importance.HIGH, + s"The mount name of the ldap authentication back end. Defaults to 'ldap'. $AUTH_METHOD must be 'ldap'" + ) + //jwt + .define( + JWT_ROLE, + Type.STRING, + null, + Importance.HIGH, + s"Role the JWT token belongs to. $AUTH_METHOD must be 'jwt'" + ) + .define( + JWT_PROVIDER, + Type.STRING, + null, + Importance.HIGH, + s"Provider of JWT token. $AUTH_METHOD must be 'jwt'" + ) + .define( + JWT, + Type.PASSWORD, + null, + Importance.HIGH, + s"JWT token. $AUTH_METHOD must be 'jwt'" + ) + //gcp + .define( + GCP_ROLE, + Type.STRING, + null, + Importance.HIGH, + s"The gcp role used for authentication. $AUTH_METHOD must be 'gcp'" + ) + .define( + GCP_JWT, + Type.PASSWORD, + null, + Importance.HIGH, + s"JWT token. $AUTH_METHOD must be 'gcp'" + ) + // cert mount + .define( + CERT_MOUNT, + Type.STRING, + "cert", + Importance.HIGH, + s"The mount name of the cert authentication back end. Defaults to 'cert'. $AUTH_METHOD must be 'cert'" + ) + .define( + GITHUB_TOKEN, + Type.PASSWORD, + null, + Importance.HIGH, + s"The github app-id used for authentication. $AUTH_METHOD must be 'github'" + ) + .define( + GITHUB_MOUNT, + Type.STRING, + "github", + Importance.HIGH, + s"The mount name of the github authentication back end. Defaults to 'cert'. $AUTH_METHOD must be 'github'" + ) + .define( + FILE_DIR, + Type.STRING, + "", + Importance.MEDIUM, + FILE_DIR_DESC + ) + .define( + TOKEN_RENEWAL, + Type.INT, + TOKEN_RENEWAL_DEFAULT, + Importance.MEDIUM, + "The time in milliseconds to renew the Vault token" + ) +} +case class VaultProviderConfig(props: util.Map[String, _]) + extends AbstractConfig(VaultProviderConfig.config, props) diff --git a/src/main/scala/io/lenses/connect/secrets/config/VaultProviderSettings.scala b/src/main/scala/io/lenses/connect/secrets/config/VaultProviderSettings.scala index deedd28..e7b61e9 100644 --- a/src/main/scala/io/lenses/connect/secrets/config/VaultProviderSettings.scala +++ b/src/main/scala/io/lenses/connect/secrets/config/VaultProviderSettings.scala @@ -1,207 +1,215 @@ -/* - * - * * Copyright 2017-2020 Lenses.io Ltd - * - */ - -package io.lenses.connect.secrets.config - -import com.typesafe.scalalogging.StrictLogging -import io.lenses.connect.secrets.config.AbstractConfigExtensions._ -import io.lenses.connect.secrets.config.VaultAuthMethod.VaultAuthMethod -import io.lenses.connect.secrets.config.VaultProviderConfig.TOKEN_RENEWAL -import io.lenses.connect.secrets.connect._ -import org.apache.kafka.common.config.types.Password -import org.apache.kafka.connect.errors.ConnectException - -import scala.concurrent.duration.{FiniteDuration, _} -import scala.io.Source -import scala.util.{Failure, Success, Using} - -case class AwsIam( - role: String, - url: String, - headers: Password, - body: Password, - mount: String -) -case class Gcp(role: String, jwt: Password) -case class Jwt(role: String, provider: String, jwt: Password) -case class UserPass(username: String, password: Password, mount: String) -case class Ldap(username: String, password: Password, mount: String) -case class AppRole(role: String, secretId: Password) -case class K8s(role: String, jwt: Password) -case class Cert(mount: String) -case class Github(token: Password, mount: String) - -case class VaultSettings( - addr: String, - namespace: String, - token: Password, - authMode: VaultAuthMethod, - keystoreLoc: String, - keystorePass: Password, - truststoreLoc: String, - pem: String, - clientPem: String, - engineVersion: Int = 2, - appRole: Option[AppRole], - awsIam: Option[AwsIam], - gcp: Option[Gcp], - jwt: Option[Jwt], - userPass: Option[UserPass], - ldap: Option[Ldap], - k8s: Option[K8s], - cert: Option[Cert], - github: Option[Github], - fileDir: String, - tokenRenewal: FiniteDuration -) - -object VaultSettings extends StrictLogging { - def apply(config: VaultProviderConfig): VaultSettings = { - val addr = config.getString(VaultProviderConfig.VAULT_ADDR) - val token = config.getPassword(VaultProviderConfig.VAULT_TOKEN) - val namespace = config.getString(VaultProviderConfig.VAULT_NAMESPACE) - val keystoreLoc = config.getString(VaultProviderConfig.VAULT_KEYSTORE_LOC) - val keystorePass = - config.getPassword(VaultProviderConfig.VAULT_KEYSTORE_PASS) - val truststoreLoc = - config.getString(VaultProviderConfig.VAULT_TRUSTSTORE_LOC) - val pem = config.getString(VaultProviderConfig.VAULT_PEM) - val clientPem = config.getString(VaultProviderConfig.VAULT_CLIENT_PEM) - val engineVersion = config.getInt(VaultProviderConfig.VAULT_ENGINE_VERSION) - - val authMode = VaultAuthMethod.withNameOpt( - config.getString(VaultProviderConfig.AUTH_METHOD).toUpperCase - ) match { - case Some(auth) => auth - case None => - throw new ConnectException( - s"Unsupported ${VaultProviderConfig.AUTH_METHOD}" - ) - } - - val awsIam = - if (authMode.equals(VaultAuthMethod.AWSIAM)) Some(getAWS(config)) - else None - val gcp = - if (authMode.equals(VaultAuthMethod.GCP)) Some(getGCP(config)) else None - val appRole = - if (authMode.equals(VaultAuthMethod.APPROLE)) Some(getAppRole(config)) - else None - val jwt = - if (authMode.equals(VaultAuthMethod.JWT)) Some(getJWT(config)) else None - val k8s = - if (authMode.equals(VaultAuthMethod.KUBERNETES)) Some(getK8s(config)) - else None - val userpass = - if (authMode.equals(VaultAuthMethod.USERPASS)) Some(getUserPass(config)) - else None - val ldap = - if (authMode.equals(VaultAuthMethod.LDAP)) Some(getLDAP(config)) else None - val cert = - if (authMode.equals(VaultAuthMethod.CERT)) Some(getCert(config)) else None - val github = - if (authMode.equals(VaultAuthMethod.GITHUB)) Some(getGitHub(config)) - else None - - val fileDir = config.getString(FILE_DIR) - - val tokenRenewal = config.getInt(TOKEN_RENEWAL).toInt.milliseconds - VaultSettings( - addr = addr, - namespace = namespace, - token = token, - authMode = authMode, - keystoreLoc = keystoreLoc, - keystorePass = keystorePass, - truststoreLoc = truststoreLoc, - pem = pem, - clientPem = clientPem, - engineVersion = engineVersion, - appRole = appRole, - awsIam = awsIam, - gcp = gcp, - jwt = jwt, - userPass = userpass, - ldap = ldap, - k8s = k8s, - cert = cert, - github = github, - fileDir = fileDir, - tokenRenewal = tokenRenewal - ) - } - - def getCert(config: VaultProviderConfig): Cert = - Cert(config.getString(VaultProviderConfig.CERT_MOUNT)) - - def getGitHub(config: VaultProviderConfig): Github = { - val token = config.getPasswordOrThrowOnNull(VaultProviderConfig.GITHUB_TOKEN) - val mount = config.getStringOrThrowOnNull(VaultProviderConfig.GITHUB_MOUNT) - Github(token = token, mount = mount) - } - - def getAWS(config: VaultProviderConfig): AwsIam = { - val role = config.getStringOrThrowOnNull(VaultProviderConfig.AWS_ROLE) - val url = config.getStringOrThrowOnNull(VaultProviderConfig.AWS_REQUEST_URL) - val headers = config.getPasswordOrThrowOnNull(VaultProviderConfig.AWS_REQUEST_HEADERS) - val body = config.getPasswordOrThrowOnNull(VaultProviderConfig.AWS_REQUEST_BODY) - val mount = config.getStringOrThrowOnNull(VaultProviderConfig.AWS_MOUNT) - AwsIam( - role = role, - url = url, - headers = headers, - body = body, - mount = mount - ) - } - - def getAppRole(config: VaultProviderConfig): AppRole = { - val role = config.getStringOrThrowOnNull(VaultProviderConfig.APP_ROLE) - val secretId = config.getPasswordOrThrowOnNull(VaultProviderConfig.APP_ROLE_SECRET_ID) - AppRole(role = role, secretId = secretId) - } - - def getK8s(config: VaultProviderConfig): K8s = { - val role = config.getStringOrThrowOnNull(VaultProviderConfig.KUBERNETES_ROLE) - val path = config.getStringOrThrowOnNull(VaultProviderConfig.KUBERNETES_TOKEN_PATH) - Using(Source.fromFile(path))(_.getLines().mkString) match { - case Failure(exception) => - throw new ConnectException( - s"Failed to load kubernetes token file [$path]", - exception - ) - case Success(fileContents) => - K8s(role = role, jwt = new Password(fileContents)) - } - } - - def getUserPass(config: VaultProviderConfig): UserPass = { - val user = config.getStringOrThrowOnNull(VaultProviderConfig.USERNAME) - val pass = config.getPasswordOrThrowOnNull(VaultProviderConfig.PASSWORD) - val mount = config.getStringOrThrowOnNull(VaultProviderConfig.UP_MOUNT) - UserPass(username = user, password = pass, mount = mount) - } - - def getLDAP(config: VaultProviderConfig): Ldap = { - val user = config.getStringOrThrowOnNull(VaultProviderConfig.LDAP_USERNAME) - val pass = config.getPasswordOrThrowOnNull(VaultProviderConfig.LDAP_PASSWORD) - val mount = config.getStringOrThrowOnNull(VaultProviderConfig.LDAP_MOUNT) - Ldap(username = user, password = pass, mount = mount) - } - - def getGCP(config: VaultProviderConfig): Gcp = { - val role = config.getStringOrThrowOnNull(VaultProviderConfig.GCP_ROLE) - val jwt = config.getPasswordOrThrowOnNull(VaultProviderConfig.GCP_JWT) - Gcp(role = role, jwt = jwt) - } - - def getJWT(config: VaultProviderConfig): Jwt = { - val role = config.getStringOrThrowOnNull(VaultProviderConfig.JWT_ROLE) - val provider = config.getStringOrThrowOnNull(VaultProviderConfig.JWT_PROVIDER) - val jwt = config.getPasswordOrThrowOnNull(VaultProviderConfig.JWT) - Jwt(role = role, provider = provider, jwt = jwt) - } -} +/* + * + * * Copyright 2017-2020 Lenses.io Ltd + * + */ + +package io.lenses.connect.secrets.config + +import com.typesafe.scalalogging.StrictLogging +import io.lenses.connect.secrets.config.AbstractConfigExtensions._ +import io.lenses.connect.secrets.config.VaultAuthMethod.VaultAuthMethod +import io.lenses.connect.secrets.config.VaultProviderConfig.TOKEN_RENEWAL +import io.lenses.connect.secrets.connect._ +import org.apache.kafka.common.config.types.Password +import org.apache.kafka.connect.errors.ConnectException + +import scala.concurrent.duration.{FiniteDuration, _} +import scala.io.Source +import scala.util.{Failure, Success, Using} + +case class AwsIam( + role: String, + url: String, + headers: Password, + body: Password, + mount: String +) +case class Gcp(role: String, jwt: Password) +case class Jwt(role: String, provider: String, jwt: Password) +case class UserPass(username: String, password: Password, mount: String) +case class Ldap(username: String, password: Password, mount: String) +case class AppRole(role: String, secretId: Password) +case class K8s(role: String, jwt: Password) +case class Cert(mount: String) +case class Github(token: Password, mount: String) + +case class VaultSettings( + addr: String, + namespace: String, + token: Password, + authMode: VaultAuthMethod, + keystoreLoc: String, + keystorePass: Password, + truststoreLoc: String, + pem: String, + clientPem: String, + engineVersion: Int = 2, + appRole: Option[AppRole], + awsIam: Option[AwsIam], + gcp: Option[Gcp], + jwt: Option[Jwt], + userPass: Option[UserPass], + ldap: Option[Ldap], + k8s: Option[K8s], + cert: Option[Cert], + github: Option[Github], + fileDir: String, + tokenRenewal: FiniteDuration +) + +object VaultSettings extends StrictLogging { + def apply(config: VaultProviderConfig): VaultSettings = { + val addr = config.getString(VaultProviderConfig.VAULT_ADDR) + val token = config.getPassword(VaultProviderConfig.VAULT_TOKEN) + val namespace = config.getString(VaultProviderConfig.VAULT_NAMESPACE) + val keystoreLoc = config.getString(VaultProviderConfig.VAULT_KEYSTORE_LOC) + val keystorePass = + config.getPassword(VaultProviderConfig.VAULT_KEYSTORE_PASS) + val truststoreLoc = + config.getString(VaultProviderConfig.VAULT_TRUSTSTORE_LOC) + val pem = config.getString(VaultProviderConfig.VAULT_PEM) + val clientPem = config.getString(VaultProviderConfig.VAULT_CLIENT_PEM) + val engineVersion = config.getInt(VaultProviderConfig.VAULT_ENGINE_VERSION) + + val authMode = VaultAuthMethod.withNameOpt( + config.getString(VaultProviderConfig.AUTH_METHOD).toUpperCase + ) match { + case Some(auth) => auth + case None => + throw new ConnectException( + s"Unsupported ${VaultProviderConfig.AUTH_METHOD}" + ) + } + + val awsIam = + if (authMode.equals(VaultAuthMethod.AWSIAM)) Some(getAWS(config)) + else None + val gcp = + if (authMode.equals(VaultAuthMethod.GCP)) Some(getGCP(config)) else None + val appRole = + if (authMode.equals(VaultAuthMethod.APPROLE)) Some(getAppRole(config)) + else None + val jwt = + if (authMode.equals(VaultAuthMethod.JWT)) Some(getJWT(config)) else None + val k8s = + if (authMode.equals(VaultAuthMethod.KUBERNETES)) Some(getK8s(config)) + else None + val userpass = + if (authMode.equals(VaultAuthMethod.USERPASS)) Some(getUserPass(config)) + else None + val ldap = + if (authMode.equals(VaultAuthMethod.LDAP)) Some(getLDAP(config)) else None + val cert = + if (authMode.equals(VaultAuthMethod.CERT)) Some(getCert(config)) else None + val github = + if (authMode.equals(VaultAuthMethod.GITHUB)) Some(getGitHub(config)) + else None + + val fileDir = config.getString(FILE_DIR) + + val tokenRenewal = config.getInt(TOKEN_RENEWAL).toInt.milliseconds + VaultSettings( + addr = addr, + namespace = namespace, + token = token, + authMode = authMode, + keystoreLoc = keystoreLoc, + keystorePass = keystorePass, + truststoreLoc = truststoreLoc, + pem = pem, + clientPem = clientPem, + engineVersion = engineVersion, + appRole = appRole, + awsIam = awsIam, + gcp = gcp, + jwt = jwt, + userPass = userpass, + ldap = ldap, + k8s = k8s, + cert = cert, + github = github, + fileDir = fileDir, + tokenRenewal = tokenRenewal + ) + } + + def getCert(config: VaultProviderConfig): Cert = + Cert(config.getString(VaultProviderConfig.CERT_MOUNT)) + + def getGitHub(config: VaultProviderConfig): Github = { + val token = + config.getPasswordOrThrowOnNull(VaultProviderConfig.GITHUB_TOKEN) + val mount = config.getStringOrThrowOnNull(VaultProviderConfig.GITHUB_MOUNT) + Github(token = token, mount = mount) + } + + def getAWS(config: VaultProviderConfig): AwsIam = { + val role = config.getStringOrThrowOnNull(VaultProviderConfig.AWS_ROLE) + val url = config.getStringOrThrowOnNull(VaultProviderConfig.AWS_REQUEST_URL) + val headers = + config.getPasswordOrThrowOnNull(VaultProviderConfig.AWS_REQUEST_HEADERS) + val body = + config.getPasswordOrThrowOnNull(VaultProviderConfig.AWS_REQUEST_BODY) + val mount = config.getStringOrThrowOnNull(VaultProviderConfig.AWS_MOUNT) + AwsIam( + role = role, + url = url, + headers = headers, + body = body, + mount = mount + ) + } + + def getAppRole(config: VaultProviderConfig): AppRole = { + val role = config.getStringOrThrowOnNull(VaultProviderConfig.APP_ROLE) + val secretId = + config.getPasswordOrThrowOnNull(VaultProviderConfig.APP_ROLE_SECRET_ID) + AppRole(role = role, secretId = secretId) + } + + def getK8s(config: VaultProviderConfig): K8s = { + val role = + config.getStringOrThrowOnNull(VaultProviderConfig.KUBERNETES_ROLE) + val path = + config.getStringOrThrowOnNull(VaultProviderConfig.KUBERNETES_TOKEN_PATH) + Using(Source.fromFile(path))(_.getLines().mkString) match { + case Failure(exception) => + throw new ConnectException( + s"Failed to load kubernetes token file [$path]", + exception + ) + case Success(fileContents) => + K8s(role = role, jwt = new Password(fileContents)) + } + } + + def getUserPass(config: VaultProviderConfig): UserPass = { + val user = config.getStringOrThrowOnNull(VaultProviderConfig.USERNAME) + val pass = config.getPasswordOrThrowOnNull(VaultProviderConfig.PASSWORD) + val mount = config.getStringOrThrowOnNull(VaultProviderConfig.UP_MOUNT) + UserPass(username = user, password = pass, mount = mount) + } + + def getLDAP(config: VaultProviderConfig): Ldap = { + val user = config.getStringOrThrowOnNull(VaultProviderConfig.LDAP_USERNAME) + val pass = + config.getPasswordOrThrowOnNull(VaultProviderConfig.LDAP_PASSWORD) + val mount = config.getStringOrThrowOnNull(VaultProviderConfig.LDAP_MOUNT) + Ldap(username = user, password = pass, mount = mount) + } + + def getGCP(config: VaultProviderConfig): Gcp = { + val role = config.getStringOrThrowOnNull(VaultProviderConfig.GCP_ROLE) + val jwt = config.getPasswordOrThrowOnNull(VaultProviderConfig.GCP_JWT) + Gcp(role = role, jwt = jwt) + } + + def getJWT(config: VaultProviderConfig): Jwt = { + val role = config.getStringOrThrowOnNull(VaultProviderConfig.JWT_ROLE) + val provider = + config.getStringOrThrowOnNull(VaultProviderConfig.JWT_PROVIDER) + val jwt = config.getPasswordOrThrowOnNull(VaultProviderConfig.JWT) + Jwt(role = role, provider = provider, jwt = jwt) + } +} diff --git a/src/main/scala/io/lenses/connect/secrets/io/FileWriter.scala b/src/main/scala/io/lenses/connect/secrets/io/FileWriter.scala index 9ae31b5..c4a3326 100644 --- a/src/main/scala/io/lenses/connect/secrets/io/FileWriter.scala +++ b/src/main/scala/io/lenses/connect/secrets/io/FileWriter.scala @@ -24,11 +24,14 @@ class FileWriterOnce(rootPath: Path) with StrictLogging { private val folderPermissions = PosixFilePermissions.fromString("rwx------") - private val filePermissions = PosixFilePermissions.fromString("rw-------") - private val folderAttributes = PosixFilePermissions.asFileAttribute(folderPermissions) - private val fileAttributes = PosixFilePermissions.asFileAttribute(filePermissions) + private val filePermissions = PosixFilePermissions.fromString("rw-------") + private val folderAttributes = + PosixFilePermissions.asFileAttribute(folderPermissions) + private val fileAttributes = + PosixFilePermissions.asFileAttribute(filePermissions) - if (!rootPath.toFile.exists) Files.createDirectories(rootPath, folderAttributes) + if (!rootPath.toFile.exists) + Files.createDirectories(rootPath, folderAttributes) def write(fileName: String, content: Array[Byte], key: String): Path = { val fullPath = Paths.get(rootPath.toString, fileName) diff --git a/src/main/scala/io/lenses/connect/secrets/package.scala b/src/main/scala/io/lenses/connect/secrets/package.scala index d5931c2..2334ecb 100644 --- a/src/main/scala/io/lenses/connect/secrets/package.scala +++ b/src/main/scala/io/lenses/connect/secrets/package.scala @@ -1,172 +1,172 @@ -/* - * - * * Copyright 2017-2020 Lenses.io Ltd - * - */ - -package io.lenses.connect.secrets - -import com.typesafe.scalalogging.StrictLogging -import org.apache.kafka.common.config.ConfigData -import org.apache.kafka.connect.errors.ConnectException - -import java.io.{File, FileOutputStream} -import java.time.OffsetDateTime -import java.util.Base64 -import scala.collection.mutable -import scala.jdk.CollectionConverters._ -import scala.util.{Failure, Success, Try} - -package object connect extends StrictLogging { - - val FILE_ENCODING: String = "file-encoding" - val FILE_DIR: String = "file.dir" - val FILE_DIR_DESC: String = - """ - | Location to write any files for any secrets that need to - | be written to disk. For example java keystores. - | Files will be written under this directory following the - | pattern /file.dir/[path|keyvault]/key - |""".stripMargin - - object AuthMode extends Enumeration { - type AuthMode = Value - val DEFAULT, CREDENTIALS = Value - def withNameOpt(s: String): Option[Value] = values.find(_.toString == s) - } - - object Encoding extends Enumeration { - type Encoding = Value - val BASE64, BASE64_FILE, UTF8, UTF8_FILE = Value - def withNameOpt(s: String): Option[Value] = values.find(_.toString == s) - } - - // get the authmethod - def getAuthenticationMethod(method: String): AuthMode.Value = { - AuthMode.withNameOpt(method.toUpperCase) match { - case Some(auth) => auth - case None => - throw new ConnectException( - s"Unsupported authentication method" - ) - } - } - - // base64 decode secret - def decode(key: String, value: String): String = { - Try(Base64.getDecoder.decode(value)) match { - case Success(decoded) => decoded.map(_.toChar).mkString - case Failure(exception) => - throw new ConnectException( - s"Failed to decode value for key [$key]", - exception - ) - } - } - - def decodeToBytes(key: String, value: String): Array[Byte] = { - Try(Base64.getDecoder.decode(value)) match { - case Success(decoded) => decoded - case Failure(exception) => - throw new ConnectException( - s"Failed to decode value for key [$key]", - exception - ) - } - } - - // decode a key bases on the prefix encoding - def decodeKey( - encoding: Option[Encoding.Value], - key: String, - value: String, - writeFileFn: Array[Byte] => String - ): String = { - encoding.fold(value) { - case Encoding.BASE64 => decode(key, value) - case Encoding.BASE64_FILE => - val decoded = decodeToBytes(key, value) - writeFileFn(decoded) - case Encoding.UTF8 => value - case Encoding.UTF8_FILE => writeFileFn(value.getBytes()) - } - } - - // write secrets to file - private def writer(file: File, payload: Array[Byte], key: String): Unit = { - Try(file.createNewFile()) match { - case Success(_) => - Try(new FileOutputStream(file)) match { - case Success(fos) => - fos.write(payload) - logger.info( - s"Payload written to [${file.getAbsolutePath}] for key [$key]" - ) - - case Failure(exception) => - throw new ConnectException( - s"Failed to write payload to file [${file.getAbsolutePath}] for key [$key]", - exception - ) - } - - case Failure(exception) => - throw new ConnectException( - s"Failed to create file [${file.getAbsolutePath}] for key [$key]", - exception - ) - } - } - - // write secrets to a file - def fileWriter( - fileName: String, - payload: Array[Byte], - key: String, - overwrite: Boolean = false - ): Unit = { - val file = new File(fileName) - file.getParentFile.mkdirs() - - if (file.exists()) { - logger.warn(s"File [$fileName] already exists") - if (overwrite) { - writer(file, payload, key) - } - } else { - writer(file, payload, key) - } - } - - //calculate the min expiry for secrets and return the configData and expiry - def getSecretsAndExpiry( - secrets: Map[String, (String, Option[OffsetDateTime])] - ): (Option[OffsetDateTime], ConfigData) = { - val expiryList = mutable.ListBuffer.empty[OffsetDateTime] - - val data = secrets - .map({ - case (key, (value, expiry)) => - expiry.foreach(e => expiryList.append(e)) - (key, value) - }) - .asJava - - if (expiryList.isEmpty) { - (None, new ConfigData(data)) - } else { - val minExpiry = expiryList.min - val ttl = minExpiry.toInstant.toEpochMilli - OffsetDateTime.now.toInstant - .toEpochMilli - (Some(minExpiry), new ConfigData(data, ttl)) - } - } - - def getFileName( - rootDir: String, - path: String, - key: String, - separator: String - ): String = - s"${rootDir.stripSuffix(separator)}$separator$path$separator${key.toLowerCase}" -} +/* + * + * * Copyright 2017-2020 Lenses.io Ltd + * + */ + +package io.lenses.connect.secrets + +import com.typesafe.scalalogging.StrictLogging +import org.apache.kafka.common.config.ConfigData +import org.apache.kafka.connect.errors.ConnectException + +import java.io.{File, FileOutputStream} +import java.time.OffsetDateTime +import java.util.Base64 +import scala.collection.mutable +import scala.jdk.CollectionConverters._ +import scala.util.{Failure, Success, Try} + +package object connect extends StrictLogging { + + val FILE_ENCODING: String = "file-encoding" + val FILE_DIR: String = "file.dir" + val FILE_DIR_DESC: String = + """ + | Location to write any files for any secrets that need to + | be written to disk. For example java keystores. + | Files will be written under this directory following the + | pattern /file.dir/[path|keyvault]/key + |""".stripMargin + + object AuthMode extends Enumeration { + type AuthMode = Value + val DEFAULT, CREDENTIALS = Value + def withNameOpt(s: String): Option[Value] = values.find(_.toString == s) + } + + object Encoding extends Enumeration { + type Encoding = Value + val BASE64, BASE64_FILE, UTF8, UTF8_FILE = Value + def withNameOpt(s: String): Option[Value] = values.find(_.toString == s) + } + + // get the authmethod + def getAuthenticationMethod(method: String): AuthMode.Value = { + AuthMode.withNameOpt(method.toUpperCase) match { + case Some(auth) => auth + case None => + throw new ConnectException( + s"Unsupported authentication method" + ) + } + } + + // base64 decode secret + def decode(key: String, value: String): String = { + Try(Base64.getDecoder.decode(value)) match { + case Success(decoded) => decoded.map(_.toChar).mkString + case Failure(exception) => + throw new ConnectException( + s"Failed to decode value for key [$key]", + exception + ) + } + } + + def decodeToBytes(key: String, value: String): Array[Byte] = { + Try(Base64.getDecoder.decode(value)) match { + case Success(decoded) => decoded + case Failure(exception) => + throw new ConnectException( + s"Failed to decode value for key [$key]", + exception + ) + } + } + + // decode a key bases on the prefix encoding + def decodeKey( + encoding: Option[Encoding.Value], + key: String, + value: String, + writeFileFn: Array[Byte] => String + ): String = { + encoding.fold(value) { + case Encoding.BASE64 => decode(key, value) + case Encoding.BASE64_FILE => + val decoded = decodeToBytes(key, value) + writeFileFn(decoded) + case Encoding.UTF8 => value + case Encoding.UTF8_FILE => writeFileFn(value.getBytes()) + } + } + + // write secrets to file + private def writer(file: File, payload: Array[Byte], key: String): Unit = { + Try(file.createNewFile()) match { + case Success(_) => + Try(new FileOutputStream(file)) match { + case Success(fos) => + fos.write(payload) + logger.info( + s"Payload written to [${file.getAbsolutePath}] for key [$key]" + ) + + case Failure(exception) => + throw new ConnectException( + s"Failed to write payload to file [${file.getAbsolutePath}] for key [$key]", + exception + ) + } + + case Failure(exception) => + throw new ConnectException( + s"Failed to create file [${file.getAbsolutePath}] for key [$key]", + exception + ) + } + } + + // write secrets to a file + def fileWriter( + fileName: String, + payload: Array[Byte], + key: String, + overwrite: Boolean = false + ): Unit = { + val file = new File(fileName) + file.getParentFile.mkdirs() + + if (file.exists()) { + logger.warn(s"File [$fileName] already exists") + if (overwrite) { + writer(file, payload, key) + } + } else { + writer(file, payload, key) + } + } + + //calculate the min expiry for secrets and return the configData and expiry + def getSecretsAndExpiry( + secrets: Map[String, (String, Option[OffsetDateTime])] + ): (Option[OffsetDateTime], ConfigData) = { + val expiryList = mutable.ListBuffer.empty[OffsetDateTime] + + val data = secrets + .map({ + case (key, (value, expiry)) => + expiry.foreach(e => expiryList.append(e)) + (key, value) + }) + .asJava + + if (expiryList.isEmpty) { + (None, new ConfigData(data)) + } else { + val minExpiry = expiryList.min + val ttl = minExpiry.toInstant.toEpochMilli - OffsetDateTime.now.toInstant + .toEpochMilli + (Some(minExpiry), new ConfigData(data, ttl)) + } + } + + def getFileName( + rootDir: String, + path: String, + key: String, + separator: String + ): String = + s"${rootDir.stripSuffix(separator)}$separator$path$separator${key.toLowerCase}" +} diff --git a/src/main/scala/io/lenses/connect/secrets/providers/AWSHelper.scala b/src/main/scala/io/lenses/connect/secrets/providers/AWSHelper.scala index 77a374e..cb75a56 100644 --- a/src/main/scala/io/lenses/connect/secrets/providers/AWSHelper.scala +++ b/src/main/scala/io/lenses/connect/secrets/providers/AWSHelper.scala @@ -1,133 +1,151 @@ -/* - * - * * Copyright 2017-2020 Lenses.io Ltd - * - */ - -package io.lenses.connect.secrets.providers - -import com.amazonaws.auth.{AWSStaticCredentialsProvider, BasicAWSCredentials, DefaultAWSCredentialsProviderChain} -import com.amazonaws.services.secretsmanager.model.{DescribeSecretRequest, GetSecretValueRequest} -import com.amazonaws.services.secretsmanager.{AWSSecretsManager, AWSSecretsManagerClientBuilder} -import com.fasterxml.jackson.databind.ObjectMapper -import com.typesafe.scalalogging.StrictLogging -import io.lenses.connect.secrets.config.AWSProviderSettings -import io.lenses.connect.secrets.connect.{AuthMode, decodeKey} -import io.lenses.connect.secrets.io.{FileWriter, FileWriterOnce} -import io.lenses.connect.secrets.utils.EncodingAndId -import org.apache.kafka.connect.errors.ConnectException - -import java.nio.file.Paths -import java.time.OffsetDateTime -import java.util.Calendar -import scala.jdk.CollectionConverters._ -import scala.util.{Failure, Success, Try} - -trait AWSHelper extends StrictLogging { - - // initialize the AWS client based on the auth mode - def createClient(settings: AWSProviderSettings): AWSSecretsManager = { - - logger.info( - s"Initializing client with mode [${settings.authMode}]" - ) - - val credentialProvider = settings.authMode match { - case AuthMode.CREDENTIALS => - new AWSStaticCredentialsProvider(new BasicAWSCredentials(settings.accessKey, settings.secretKey.value())) - case _ => - new DefaultAWSCredentialsProviderChain() - } - - AWSSecretsManagerClientBuilder - .standard() - .withCredentials(credentialProvider) - .withRegion(settings.region) - .build() - - } - - // determine the ttl for the secret - private def getTTL( - client: AWSSecretsManager, - secretId: String - ): Option[OffsetDateTime] = { - - // describe to get the ttl - val descRequest: DescribeSecretRequest = - new DescribeSecretRequest().withSecretId(secretId) - - Try(client.describeSecret(descRequest)) match { - case Success(d) => - if (d.getRotationEnabled) { - val lastRotation = d.getLastRotatedDate - val nextRotationInDays = - d.getRotationRules.getAutomaticallyAfterDays - val cal = Calendar.getInstance() - //set to last rotation date - cal.setTime(lastRotation) - //increment - cal.add(Calendar.DAY_OF_MONTH, nextRotationInDays.toInt) - Some( - OffsetDateTime.ofInstant(cal.toInstant, cal.getTimeZone.toZoneId)) - - } else None - - case Failure(exception) => - throw new ConnectException( - s"Failed to describe secret [$secretId]", - exception - ) - } - } - - // get the key value and ttl in the specified secret - def getSecretValue( - client: AWSSecretsManager, - rootDir: String, - secretId: String, - key: String - ): (String, Option[OffsetDateTime]) = { - - // get the secret - Try( - client.getSecretValue(new GetSecretValueRequest().withSecretId(secretId)) - ) match { - case Success(secret) => - val value = - new ObjectMapper() - .readValue( - secret.getSecretString, - classOf[java.util.HashMap[String, String]] - ) - .asScala - .getOrElse( - key, - throw new ConnectException( - s"Failed to look up key [$key] in secret [${secret.getName}]. key not found" - ) - ) - - val fileWriter:FileWriter = new FileWriterOnce(Paths.get(rootDir, secretId)) - // decode the value - val encodingAndId = EncodingAndId.from(key) - ( - decodeKey( - key = key, - value = value, - encoding = encodingAndId.encoding, - writeFileFn = content=>{ - fileWriter.write(key.toLowerCase, content, key).toString - } - ), - getTTL(client, secretId) - ) - - case Failure(exception) => - throw new ConnectException( - s"Failed to look up key [$key] in secret [$secretId] due to [${exception.getMessage}]", - exception - ) - } - } -} +/* + * + * * Copyright 2017-2020 Lenses.io Ltd + * + */ + +package io.lenses.connect.secrets.providers + +import com.amazonaws.auth.{ + AWSStaticCredentialsProvider, + BasicAWSCredentials, + DefaultAWSCredentialsProviderChain +} +import com.amazonaws.services.secretsmanager.model.{ + DescribeSecretRequest, + GetSecretValueRequest +} +import com.amazonaws.services.secretsmanager.{ + AWSSecretsManager, + AWSSecretsManagerClientBuilder +} +import com.fasterxml.jackson.databind.ObjectMapper +import com.typesafe.scalalogging.StrictLogging +import io.lenses.connect.secrets.config.AWSProviderSettings +import io.lenses.connect.secrets.connect.{AuthMode, decodeKey} +import io.lenses.connect.secrets.io.{FileWriter, FileWriterOnce} +import io.lenses.connect.secrets.utils.EncodingAndId +import org.apache.kafka.connect.errors.ConnectException + +import java.nio.file.Paths +import java.time.OffsetDateTime +import java.util.Calendar +import scala.jdk.CollectionConverters._ +import scala.util.{Failure, Success, Try} + +trait AWSHelper extends StrictLogging { + + // initialize the AWS client based on the auth mode + def createClient(settings: AWSProviderSettings): AWSSecretsManager = { + + logger.info( + s"Initializing client with mode [${settings.authMode}]" + ) + + val credentialProvider = settings.authMode match { + case AuthMode.CREDENTIALS => + new AWSStaticCredentialsProvider( + new BasicAWSCredentials( + settings.accessKey, + settings.secretKey.value() + ) + ) + case _ => + new DefaultAWSCredentialsProviderChain() + } + + AWSSecretsManagerClientBuilder + .standard() + .withCredentials(credentialProvider) + .withRegion(settings.region) + .build() + + } + + // determine the ttl for the secret + private def getTTL( + client: AWSSecretsManager, + secretId: String + ): Option[OffsetDateTime] = { + + // describe to get the ttl + val descRequest: DescribeSecretRequest = + new DescribeSecretRequest().withSecretId(secretId) + + Try(client.describeSecret(descRequest)) match { + case Success(d) => + if (d.getRotationEnabled) { + val lastRotation = d.getLastRotatedDate + val nextRotationInDays = + d.getRotationRules.getAutomaticallyAfterDays + val cal = Calendar.getInstance() + //set to last rotation date + cal.setTime(lastRotation) + //increment + cal.add(Calendar.DAY_OF_MONTH, nextRotationInDays.toInt) + Some( + OffsetDateTime.ofInstant(cal.toInstant, cal.getTimeZone.toZoneId) + ) + + } else None + + case Failure(exception) => + throw new ConnectException( + s"Failed to describe secret [$secretId]", + exception + ) + } + } + + // get the key value and ttl in the specified secret + def getSecretValue( + client: AWSSecretsManager, + rootDir: String, + secretId: String, + key: String + ): (String, Option[OffsetDateTime]) = { + + // get the secret + Try( + client.getSecretValue(new GetSecretValueRequest().withSecretId(secretId)) + ) match { + case Success(secret) => + val value = + new ObjectMapper() + .readValue( + secret.getSecretString, + classOf[java.util.HashMap[String, String]] + ) + .asScala + .getOrElse( + key, + throw new ConnectException( + s"Failed to look up key [$key] in secret [${secret.getName}]. key not found" + ) + ) + + val fileWriter: FileWriter = new FileWriterOnce( + Paths.get(rootDir, secretId) + ) + // decode the value + val encodingAndId = EncodingAndId.from(key) + ( + decodeKey( + key = key, + value = value, + encoding = encodingAndId.encoding, + writeFileFn = content => { + fileWriter.write(key.toLowerCase, content, key).toString + } + ), + getTTL(client, secretId) + ) + + case Failure(exception) => + throw new ConnectException( + s"Failed to look up key [$key] in secret [$secretId] due to [${exception.getMessage}]", + exception + ) + } + } +} diff --git a/src/main/scala/io/lenses/connect/secrets/providers/AWSSecretProvider.scala b/src/main/scala/io/lenses/connect/secrets/providers/AWSSecretProvider.scala index ea05000..0cc4a1a 100644 --- a/src/main/scala/io/lenses/connect/secrets/providers/AWSSecretProvider.scala +++ b/src/main/scala/io/lenses/connect/secrets/providers/AWSSecretProvider.scala @@ -1,63 +1,66 @@ -/* - * - * * Copyright 2017-2020 Lenses.io Ltd - * - */ - -package io.lenses.connect.secrets.providers - -import com.amazonaws.services.secretsmanager.AWSSecretsManager -import io.lenses.connect.secrets.config.{AWSProviderConfig, AWSProviderSettings} -import io.lenses.connect.secrets.connect.getSecretsAndExpiry -import org.apache.kafka.common.config.ConfigData -import org.apache.kafka.common.config.provider.ConfigProvider -import org.apache.kafka.connect.errors.ConnectException - -import java.time.OffsetDateTime -import java.util -import scala.jdk.CollectionConverters._ - -class AWSSecretProvider() extends ConfigProvider with AWSHelper { - - var client: Option[AWSSecretsManager] = None - var rootDir: String = "" - - override def get(path: String): ConfigData = - new ConfigData(Map.empty[String, String].asJava) - - // path is expected to be the name of the AWS secret - // keys are expect to be the keys in the payload - override def get(path: String, keys: util.Set[String]): ConfigData = { - - client match { - case Some(awsClient) => - //aws client caches so we don't need to check here - val (expiry, data) = getSecretsAndExpiry( - getSecrets(awsClient, path, keys.asScala.toSet)) - expiry.foreach(exp => - logger.info(s"Min expiry for TTL set to [${exp.toString}]")) - data - - case None => throw new ConnectException("AWS client is not set.") - } - } - - override def close(): Unit = client.foreach(_.shutdown()) - - override def configure(configs: util.Map[String, _]): Unit = { - val settings = AWSProviderSettings(AWSProviderConfig(props = configs)) - rootDir = settings.fileDir - client = Some(createClient(settings)) - } - - def getSecrets( - awsClient: AWSSecretsManager, - path: String, - keys: Set[String]): Map[String, (String, Option[OffsetDateTime])] = { - keys.map { key => - logger.info(s"Looking up value at [$path] for key [$key]") - val (value, expiry) = getSecretValue(awsClient, rootDir, path, key) - (key, (value, expiry)) - }.toMap - } -} +/* + * + * * Copyright 2017-2020 Lenses.io Ltd + * + */ + +package io.lenses.connect.secrets.providers + +import com.amazonaws.services.secretsmanager.AWSSecretsManager +import io.lenses.connect.secrets.config.{AWSProviderConfig, AWSProviderSettings} +import io.lenses.connect.secrets.connect.getSecretsAndExpiry +import org.apache.kafka.common.config.ConfigData +import org.apache.kafka.common.config.provider.ConfigProvider +import org.apache.kafka.connect.errors.ConnectException + +import java.time.OffsetDateTime +import java.util +import scala.jdk.CollectionConverters._ + +class AWSSecretProvider() extends ConfigProvider with AWSHelper { + + var client: Option[AWSSecretsManager] = None + var rootDir: String = "" + + override def get(path: String): ConfigData = + new ConfigData(Map.empty[String, String].asJava) + + // path is expected to be the name of the AWS secret + // keys are expect to be the keys in the payload + override def get(path: String, keys: util.Set[String]): ConfigData = { + + client match { + case Some(awsClient) => + //aws client caches so we don't need to check here + val (expiry, data) = getSecretsAndExpiry( + getSecrets(awsClient, path, keys.asScala.toSet) + ) + expiry.foreach(exp => + logger.info(s"Min expiry for TTL set to [${exp.toString}]") + ) + data + + case None => throw new ConnectException("AWS client is not set.") + } + } + + override def close(): Unit = client.foreach(_.shutdown()) + + override def configure(configs: util.Map[String, _]): Unit = { + val settings = AWSProviderSettings(AWSProviderConfig(props = configs)) + rootDir = settings.fileDir + client = Some(createClient(settings)) + } + + def getSecrets( + awsClient: AWSSecretsManager, + path: String, + keys: Set[String] + ): Map[String, (String, Option[OffsetDateTime])] = { + keys.map { key => + logger.info(s"Looking up value at [$path] for key [$key]") + val (value, expiry) = getSecretValue(awsClient, rootDir, path, key) + (key, (value, expiry)) + }.toMap + } +} diff --git a/src/main/scala/io/lenses/connect/secrets/providers/Aes256DecodingHelper.scala b/src/main/scala/io/lenses/connect/secrets/providers/Aes256DecodingHelper.scala index aab0663..dfd64be 100644 --- a/src/main/scala/io/lenses/connect/secrets/providers/Aes256DecodingHelper.scala +++ b/src/main/scala/io/lenses/connect/secrets/providers/Aes256DecodingHelper.scala @@ -29,10 +29,10 @@ private[providers] object Aes256DecodingHelper { } } -private[providers] class Aes256DecodingHelper private( - key: String, - ivSeparator: String - ) { +private[providers] class Aes256DecodingHelper private ( + key: String, + ivSeparator: String +) { import Aes256DecodingHelper.CHARSET import B64._ @@ -41,15 +41,18 @@ private[providers] class Aes256DecodingHelper private( def decrypt(s: String): Try[String] = for { - (iv, encoded) <- InitializationVector.extractInitialisationVector(s, ivSeparator) + (iv, encoded) <- InitializationVector.extractInitialisationVector( + s, + ivSeparator + ) decoded <- base64Decode(encoded) decrypted <- decryptBytes(iv, decoded) } yield new String(decrypted, CHARSET) private def decryptBytes( - iv: InitializationVector, - bytes: Array[Byte] - ): Try[Array[Byte]] = + iv: InitializationVector, + bytes: Array[Byte] + ): Try[Array[Byte]] = for { cipher <- getCipher(Cipher.DECRYPT_MODE, iv) encrypted <- Try(cipher.doFinal(bytes)) @@ -64,7 +67,7 @@ private[providers] class Aes256DecodingHelper private( } } -private case class InitializationVector private(bytes: Array[Byte]) +private case class InitializationVector private (bytes: Array[Byte]) private object InitializationVector { @@ -81,9 +84,9 @@ private object InitializationVector { } def extractInitialisationVector( - s: String, - ivSeparator: String - ): Try[(InitializationVector, String)] = + s: String, + ivSeparator: String + ): Try[(InitializationVector, String)] = s.indexOf(ivSeparator) match { case -1 => Failure( diff --git a/src/main/scala/io/lenses/connect/secrets/providers/Aes256DecodingProvider.scala b/src/main/scala/io/lenses/connect/secrets/providers/Aes256DecodingProvider.scala index 3753527..43edbac 100644 --- a/src/main/scala/io/lenses/connect/secrets/providers/Aes256DecodingProvider.scala +++ b/src/main/scala/io/lenses/connect/secrets/providers/Aes256DecodingProvider.scala @@ -15,9 +15,9 @@ import scala.jdk.CollectionConverters._ class Aes256DecodingProvider extends ConfigProvider { var decoder: Option[Aes256DecodingHelper] = None - + private var fileWriter: FileWriter = _ - + override def configure(configs: util.Map[String, _]): Unit = { val aes256Cfg = Aes256ProviderConfig(configs) val aes256Key = aes256Cfg.aes256Key @@ -29,22 +29,33 @@ class Aes256DecodingProvider extends ConfigProvider { fileWriter = new FileWriterOnce(Paths.get(writeDir, "secrets")) } - override def get(path: String): ConfigData = new ConfigData(Map.empty[String, String].asJava) + override def get(path: String): ConfigData = + new ConfigData(Map.empty[String, String].asJava) override def get(path: String, keys: util.Set[String]): ConfigData = { val encodingAndId = EncodingAndId.from(path) decoder match { case Some(d) => def decrypt(key: String): String = { - val decrypted = d.decrypt(key).fold(e => throw new ConnectException("Failed to decrypt the secret.", e), identity) + val decrypted = d + .decrypt(key) + .fold( + e => + throw new ConnectException("Failed to decrypt the secret.", e), + identity + ) decodeKey( key = key, value = decrypted, encoding = encodingAndId.encoding, writeFileFn = { content => encodingAndId.id match { - case Some(value) => fileWriter.write(value, content, key).toString - case None => throw new ConnectException(s"Invalid argument received for key:$key. Expecting a file identifier.") + case Some(value) => + fileWriter.write(value, content, key).toString + case None => + throw new ConnectException( + s"Invalid argument received for key:$key. Expecting a file identifier." + ) } } ) diff --git a/src/main/scala/io/lenses/connect/secrets/providers/AzureHelper.scala b/src/main/scala/io/lenses/connect/secrets/providers/AzureHelper.scala index 2d43aa5..0acd6bd 100644 --- a/src/main/scala/io/lenses/connect/secrets/providers/AzureHelper.scala +++ b/src/main/scala/io/lenses/connect/secrets/providers/AzureHelper.scala @@ -1,105 +1,111 @@ -/* - * - * * Copyright 2017-2020 Lenses.io Ltd - * - */ - -package io.lenses.connect.secrets.providers - -import com.azure.core.credential.TokenCredential -import com.azure.identity.{ClientSecretCredentialBuilder, DefaultAzureCredentialBuilder} -import com.azure.security.keyvault.secrets.SecretClient -import com.typesafe.scalalogging.StrictLogging -import io.lenses.connect.secrets.config.AzureProviderSettings -import io.lenses.connect.secrets.connect._ -import org.apache.kafka.connect.errors.ConnectException - -import java.nio.file.FileSystems -import java.time.OffsetDateTime -import scala.util.{Failure, Success, Try} - -trait AzureHelper extends StrictLogging { - - private val separator: String = FileSystems.getDefault.getSeparator - - // look up secret in Azure - def getSecretValue( - rootDir: String, - path: String, - client: SecretClient, - key: String - ): (String, Option[OffsetDateTime]) = { - - Try(client.getSecret(key)) match { - case Success(secret) => - val value = secret.getValue - val props = secret.getProperties - - // check if the file-encoding - val encoding = - Encoding.withName( - Option(props.getTags) - .map { _.getOrDefault(FILE_ENCODING, Encoding.UTF8.toString) } - .getOrElse(Encoding.UTF8.toString) - .toUpperCase) - - val content = encoding match { - case Encoding.UTF8 => - value - - case Encoding.UTF8_FILE => - val fileName = getFileName(rootDir, path, key.toLowerCase, separator) - fileWriter( - fileName, - value.getBytes, - key.toLowerCase - ) - fileName - - case Encoding.BASE64 => - decode(key, value) - - // write to file and set the file name as the value - case Encoding.BASE64_FILE | Encoding.UTF8_FILE => - val fileName = getFileName(rootDir, path, key.toLowerCase, separator) - val decoded = decodeToBytes(key, value) - fileWriter( - fileName, - decoded, - key.toLowerCase - ) - fileName - } - - val expiry = Option(props.getExpiresOn) - (content, expiry) - - case Failure(e) => - throw new ConnectException( - s"Failed to look up secret [$key] at [${client.getVaultUrl}]", - e - ) - } - } - - // setup azure credentials - def createCredentials(settings: AzureProviderSettings): TokenCredential = { - - logger.info( - s"Initializing client with mode [${settings.authMode.toString}]" - ) - - settings.authMode match { - case AuthMode.CREDENTIALS => - new ClientSecretCredentialBuilder() - .clientId(settings.clientId) - .clientSecret(settings.secretId.value()) - .tenantId(settings.tenantId) - .build() - - case _ => - new DefaultAzureCredentialBuilder().build() - - } - } -} +/* + * + * * Copyright 2017-2020 Lenses.io Ltd + * + */ + +package io.lenses.connect.secrets.providers + +import com.azure.core.credential.TokenCredential +import com.azure.identity.{ + ClientSecretCredentialBuilder, + DefaultAzureCredentialBuilder +} +import com.azure.security.keyvault.secrets.SecretClient +import com.typesafe.scalalogging.StrictLogging +import io.lenses.connect.secrets.config.AzureProviderSettings +import io.lenses.connect.secrets.connect._ +import org.apache.kafka.connect.errors.ConnectException + +import java.nio.file.FileSystems +import java.time.OffsetDateTime +import scala.util.{Failure, Success, Try} + +trait AzureHelper extends StrictLogging { + + private val separator: String = FileSystems.getDefault.getSeparator + + // look up secret in Azure + def getSecretValue( + rootDir: String, + path: String, + client: SecretClient, + key: String + ): (String, Option[OffsetDateTime]) = { + + Try(client.getSecret(key)) match { + case Success(secret) => + val value = secret.getValue + val props = secret.getProperties + + // check if the file-encoding + val encoding = + Encoding.withName( + Option(props.getTags) + .map { _.getOrDefault(FILE_ENCODING, Encoding.UTF8.toString) } + .getOrElse(Encoding.UTF8.toString) + .toUpperCase + ) + + val content = encoding match { + case Encoding.UTF8 => + value + + case Encoding.UTF8_FILE => + val fileName = + getFileName(rootDir, path, key.toLowerCase, separator) + fileWriter( + fileName, + value.getBytes, + key.toLowerCase + ) + fileName + + case Encoding.BASE64 => + decode(key, value) + + // write to file and set the file name as the value + case Encoding.BASE64_FILE | Encoding.UTF8_FILE => + val fileName = + getFileName(rootDir, path, key.toLowerCase, separator) + val decoded = decodeToBytes(key, value) + fileWriter( + fileName, + decoded, + key.toLowerCase + ) + fileName + } + + val expiry = Option(props.getExpiresOn) + (content, expiry) + + case Failure(e) => + throw new ConnectException( + s"Failed to look up secret [$key] at [${client.getVaultUrl}]", + e + ) + } + } + + // setup azure credentials + def createCredentials(settings: AzureProviderSettings): TokenCredential = { + + logger.info( + s"Initializing client with mode [${settings.authMode.toString}]" + ) + + settings.authMode match { + case AuthMode.CREDENTIALS => + new ClientSecretCredentialBuilder() + .clientId(settings.clientId) + .clientSecret(settings.secretId.value()) + .tenantId(settings.tenantId) + .build() + + case _ => + new DefaultAzureCredentialBuilder().build() + + } + } +} diff --git a/src/main/scala/io/lenses/connect/secrets/providers/AzureSecretProvider.scala b/src/main/scala/io/lenses/connect/secrets/providers/AzureSecretProvider.scala index d2e114b..41efa0a 100644 --- a/src/main/scala/io/lenses/connect/secrets/providers/AzureSecretProvider.scala +++ b/src/main/scala/io/lenses/connect/secrets/providers/AzureSecretProvider.scala @@ -1,105 +1,119 @@ -/* - * - * * Copyright 2017-2020 Lenses.io Ltd - * - */ - -package io.lenses.connect.secrets.providers - -import com.azure.core.credential.TokenCredential -import com.azure.security.keyvault.secrets.{SecretClient, SecretClientBuilder} -import io.lenses.connect.secrets.config.{AzureProviderConfig, AzureProviderSettings} -import io.lenses.connect.secrets.connect.getSecretsAndExpiry -import org.apache.kafka.common.config.ConfigData -import org.apache.kafka.common.config.provider.ConfigProvider - -import java.time.OffsetDateTime -import java.util -import scala.collection.mutable -import scala.jdk.CollectionConverters._ - -class AzureSecretProvider() extends ConfigProvider with AzureHelper { - - private var rootDir: String = _ - private var credentials: Option[TokenCredential] = None - val clientMap: mutable.Map[String, SecretClient] = mutable.Map.empty - val cache = mutable.Map.empty[String, (Option[OffsetDateTime], ConfigData)] - - // configure the vault client - override def configure(configs: util.Map[String, _]): Unit = { - val settings = AzureProviderSettings(AzureProviderConfig(configs)) - rootDir = settings.fileDir - credentials = Some(createCredentials(settings)) - } - - // lookup secrets at a path - // returns and empty map since Azure is flat - // and we need to know the secret to lookup - override def get(path: String): ConfigData = - new ConfigData(Map.empty[String, String].asJava) - - // get secret keys at a path - // paths is expected to be the url of the azure keyvault without the protocol (https://) - // since the connect work will not parse it correctly do to the : - // including the azure environment. - override def get(path: String, keys: util.Set[String]): ConfigData = { - - val keyVaultUrl = - if (path.startsWith("https://")) path else s"https://$path" - - // don't need to cache but allows for testing - // this way we don't require a keyvault set in the - // worker properties and we take the path as the target keyvault - val client = clientMap.getOrElse( - keyVaultUrl, - new SecretClientBuilder() - .vaultUrl(keyVaultUrl) - .credential(credentials.get) - .buildClient - ) - - clientMap += (keyVaultUrl -> client) - - val (expiry, data) = cache.get(keyVaultUrl) match { - case Some((expiresAt, data)) => - // we have all the keys and are before the expiry - val now = OffsetDateTime.now() - - if (keys.asScala.subsetOf(data.data().asScala.keySet) && (expiresAt - .getOrElse(now.plusSeconds(1)) - .isAfter(now))) { - logger.info("Fetching secrets from cache") - (expiresAt, - new ConfigData( - data.data().asScala.view.filter{ - case (k,_) => keys.contains(k) - }.toMap.asJava, - data.ttl())) - } else { - // missing some or expired so reload - getSecretsAndExpiry(getSecrets(client, keys.asScala.toSet)) - } - - case None => - getSecretsAndExpiry(getSecrets(client, keys.asScala.toSet)) - } - - expiry.foreach(exp => - logger.info(s"Min expiry for TTL set to [${exp.toString}]")) - cache += (keyVaultUrl -> (expiry, data)) - data - } - - override def close(): Unit = {} - - private def getSecrets( - client: SecretClient, - keys: Set[String]): Map[String, (String, Option[OffsetDateTime])] = { - val path = client.getVaultUrl.stripPrefix("https://") - keys.map { key => - logger.info(s"Looking up value at [$path] for key [$key]") - val (value, expiry) = getSecretValue(rootDir, path, client, key) - (key, (value, expiry)) - }.toMap - } -} +/* + * + * * Copyright 2017-2020 Lenses.io Ltd + * + */ + +package io.lenses.connect.secrets.providers + +import com.azure.core.credential.TokenCredential +import com.azure.security.keyvault.secrets.{SecretClient, SecretClientBuilder} +import io.lenses.connect.secrets.config.{ + AzureProviderConfig, + AzureProviderSettings +} +import io.lenses.connect.secrets.connect.getSecretsAndExpiry +import org.apache.kafka.common.config.ConfigData +import org.apache.kafka.common.config.provider.ConfigProvider + +import java.time.OffsetDateTime +import java.util +import scala.collection.mutable +import scala.jdk.CollectionConverters._ + +class AzureSecretProvider() extends ConfigProvider with AzureHelper { + + private var rootDir: String = _ + private var credentials: Option[TokenCredential] = None + val clientMap: mutable.Map[String, SecretClient] = mutable.Map.empty + val cache = mutable.Map.empty[String, (Option[OffsetDateTime], ConfigData)] + + // configure the vault client + override def configure(configs: util.Map[String, _]): Unit = { + val settings = AzureProviderSettings(AzureProviderConfig(configs)) + rootDir = settings.fileDir + credentials = Some(createCredentials(settings)) + } + + // lookup secrets at a path + // returns and empty map since Azure is flat + // and we need to know the secret to lookup + override def get(path: String): ConfigData = + new ConfigData(Map.empty[String, String].asJava) + + // get secret keys at a path + // paths is expected to be the url of the azure keyvault without the protocol (https://) + // since the connect work will not parse it correctly do to the : + // including the azure environment. + override def get(path: String, keys: util.Set[String]): ConfigData = { + + val keyVaultUrl = + if (path.startsWith("https://")) path else s"https://$path" + + // don't need to cache but allows for testing + // this way we don't require a keyvault set in the + // worker properties and we take the path as the target keyvault + val client = clientMap.getOrElse( + keyVaultUrl, + new SecretClientBuilder() + .vaultUrl(keyVaultUrl) + .credential(credentials.get) + .buildClient + ) + + clientMap += (keyVaultUrl -> client) + + val (expiry, data) = cache.get(keyVaultUrl) match { + case Some((expiresAt, data)) => + // we have all the keys and are before the expiry + val now = OffsetDateTime.now() + + if (keys.asScala.subsetOf(data.data().asScala.keySet) && (expiresAt + .getOrElse(now.plusSeconds(1)) + .isAfter(now))) { + logger.info("Fetching secrets from cache") + ( + expiresAt, + new ConfigData( + data + .data() + .asScala + .view + .filter { + case (k, _) => keys.contains(k) + } + .toMap + .asJava, + data.ttl() + ) + ) + } else { + // missing some or expired so reload + getSecretsAndExpiry(getSecrets(client, keys.asScala.toSet)) + } + + case None => + getSecretsAndExpiry(getSecrets(client, keys.asScala.toSet)) + } + + expiry.foreach(exp => + logger.info(s"Min expiry for TTL set to [${exp.toString}]") + ) + cache += (keyVaultUrl -> (expiry, data)) + data + } + + override def close(): Unit = {} + + private def getSecrets( + client: SecretClient, + keys: Set[String] + ): Map[String, (String, Option[OffsetDateTime])] = { + val path = client.getVaultUrl.stripPrefix("https://") + keys.map { key => + logger.info(s"Looking up value at [$path] for key [$key]") + val (value, expiry) = getSecretValue(rootDir, path, client, key) + (key, (value, expiry)) + }.toMap + } +} diff --git a/src/main/scala/io/lenses/connect/secrets/providers/ENVSecretProvider.scala b/src/main/scala/io/lenses/connect/secrets/providers/ENVSecretProvider.scala index 3bfdf68..4fa8353 100644 --- a/src/main/scala/io/lenses/connect/secrets/providers/ENVSecretProvider.scala +++ b/src/main/scala/io/lenses/connect/secrets/providers/ENVSecretProvider.scala @@ -1,76 +1,84 @@ -/* - * - * * Copyright 2017-2020 Lenses.io Ltd - * - */ - -package io.lenses.connect.secrets.providers - -import io.lenses.connect.secrets.config.ENVProviderConfig -import io.lenses.connect.secrets.connect.{FILE_DIR, decode, decodeToBytes, fileWriter} -import org.apache.kafka.common.config.ConfigData -import org.apache.kafka.common.config.provider.ConfigProvider -import org.apache.kafka.connect.errors.ConnectException - -import java.nio.file.FileSystems -import java.util -import scala.jdk.CollectionConverters._ - -class ENVSecretProvider extends ConfigProvider { - - var vars = Map.empty[String, String] - var fileDir: String = "" - private val separator: String = FileSystems.getDefault.getSeparator - private val BASE64_FILE = "(ENV-mounted-base64:)(.*$)".r - private val UTF8_FILE = "(ENV-mounted:)(.*$)".r - private val BASE64 = "(ENV-base64:)(.*$)".r - - override def get(path: String): ConfigData = - new ConfigData(Map.empty[String, String].asJava) - - override def get(path: String, keys: util.Set[String]): ConfigData = { - val data = - keys.asScala - .map { key => - { - val envVarVal = - vars.getOrElse(key, - throw new ConnectException( - s"Failed to lookup environment variable [$key]")) - - // match the value to see if its coming from contains - // the value metadata pattern - envVarVal match { - case BASE64_FILE(_, v) => - //decode and write to file - val fileName = s"$fileDir$separator${key.toLowerCase}" - fileWriter(fileName, decodeToBytes(key, v), key) - (key, fileName) - - case UTF8_FILE(_, v) => - val fileName = s"$fileDir$separator${key.toLowerCase}" - fileWriter(fileName, v.getBytes(), key) - (key, fileName) - - case BASE64(_, v) => - (key, decode(key, v)) - - case _ => - (key, envVarVal) - } - } - } - .toMap - .asJava - - new ConfigData(data) - } - - override def configure(configs: util.Map[String, _]): Unit = { - vars = System.getenv().asScala.toMap - val config = ENVProviderConfig(configs) - fileDir = config.getString(FILE_DIR).stripSuffix(separator) - } - - override def close(): Unit = {} -} +/* + * + * * Copyright 2017-2020 Lenses.io Ltd + * + */ + +package io.lenses.connect.secrets.providers + +import io.lenses.connect.secrets.config.ENVProviderConfig +import io.lenses.connect.secrets.connect.{ + FILE_DIR, + decode, + decodeToBytes, + fileWriter +} +import org.apache.kafka.common.config.ConfigData +import org.apache.kafka.common.config.provider.ConfigProvider +import org.apache.kafka.connect.errors.ConnectException + +import java.nio.file.FileSystems +import java.util +import scala.jdk.CollectionConverters._ + +class ENVSecretProvider extends ConfigProvider { + + var vars = Map.empty[String, String] + var fileDir: String = "" + private val separator: String = FileSystems.getDefault.getSeparator + private val BASE64_FILE = "(ENV-mounted-base64:)(.*$)".r + private val UTF8_FILE = "(ENV-mounted:)(.*$)".r + private val BASE64 = "(ENV-base64:)(.*$)".r + + override def get(path: String): ConfigData = + new ConfigData(Map.empty[String, String].asJava) + + override def get(path: String, keys: util.Set[String]): ConfigData = { + val data = + keys.asScala + .map { key => + { + val envVarVal = + vars.getOrElse( + key, + throw new ConnectException( + s"Failed to lookup environment variable [$key]" + ) + ) + + // match the value to see if its coming from contains + // the value metadata pattern + envVarVal match { + case BASE64_FILE(_, v) => + //decode and write to file + val fileName = s"$fileDir$separator${key.toLowerCase}" + fileWriter(fileName, decodeToBytes(key, v), key) + (key, fileName) + + case UTF8_FILE(_, v) => + val fileName = s"$fileDir$separator${key.toLowerCase}" + fileWriter(fileName, v.getBytes(), key) + (key, fileName) + + case BASE64(_, v) => + (key, decode(key, v)) + + case _ => + (key, envVarVal) + } + } + } + .toMap + .asJava + + new ConfigData(data) + } + + override def configure(configs: util.Map[String, _]): Unit = { + vars = System.getenv().asScala.toMap + val config = ENVProviderConfig(configs) + fileDir = config.getString(FILE_DIR).stripSuffix(separator) + } + + override def close(): Unit = {} +} diff --git a/src/main/scala/io/lenses/connect/secrets/providers/VaultHelper.scala b/src/main/scala/io/lenses/connect/secrets/providers/VaultHelper.scala index 10e2e1f..b9fef96 100644 --- a/src/main/scala/io/lenses/connect/secrets/providers/VaultHelper.scala +++ b/src/main/scala/io/lenses/connect/secrets/providers/VaultHelper.scala @@ -1,165 +1,165 @@ -/* - * - * * Copyright 2017-2020 Lenses.io Ltd - * - */ - -package io.lenses.connect.secrets.providers - -import com.bettercloud.vault.{SslConfig, Vault, VaultConfig} -import com.typesafe.scalalogging.StrictLogging -import io.lenses.connect.secrets.config.{VaultAuthMethod, VaultSettings} -import org.apache.kafka.connect.errors.ConnectException - -import java.io.File - -trait VaultHelper extends StrictLogging { - - // initialize the vault client - def createClient(settings: VaultSettings): Vault = { - val config = - new VaultConfig().address(settings.addr) - - // set ssl if configured - config.sslConfig(configureSSL(settings)) - - if (settings.namespace.nonEmpty) { - logger.info(s"Setting namespace to ${settings.namespace}") - config.nameSpace(settings.namespace) - } - - logger.info(s"Setting engine version to ${settings.engineVersion}") - config.engineVersion(settings.engineVersion) - - val vault = new Vault(config.build()) - - logger.info( - s"Initializing client with mode [${settings.authMode.toString}]" - ) - - val token = settings.authMode match { - case VaultAuthMethod.USERPASS => - settings.userPass - .map( - up => - vault - .auth() - .loginByUserPass(up.username, up.password.value(), up.mount) - .getAuthClientToken) - - case VaultAuthMethod.APPROLE => - settings.appRole - .map( - ar => - vault - .auth() - .loginByAppRole(ar.role, ar.secretId.value()) - .getAuthClientToken) - - case VaultAuthMethod.CERT => - settings.cert - .map(c => vault.auth().loginByCert(c.mount).getAuthClientToken) - - case VaultAuthMethod.AWSIAM => - settings.awsIam - .map( - aws => - vault - .auth() - .loginByAwsIam( - aws.role, - aws.url, - aws.body.value(), - aws.headers.value(), - aws.mount - ) - .getAuthClientToken) - - case VaultAuthMethod.KUBERNETES => - settings.k8s - .map( - k8s => - vault - .auth() - .loginByKubernetes(k8s.role, k8s.jwt.value()) - .getAuthClientToken) - case VaultAuthMethod.GCP => - settings.gcp - .map( - gcp => - vault - .auth() - .loginByGCP(gcp.role, gcp.jwt.value()) - .getAuthClientToken) - - case VaultAuthMethod.LDAP => - settings.ldap - .map( - l => - vault - .auth() - .loginByLDAP(l.username, l.password.value(), l.mount) - .getAuthClientToken) - - case VaultAuthMethod.JWT => - settings.jwt - .map( - j => - vault - .auth() - .loginByJwt(j.provider, j.role, j.jwt.value()) - .getAuthClientToken) - - case VaultAuthMethod.TOKEN => - Some(settings.token.value()) - - case VaultAuthMethod.GITHUB => - - settings.github - .map( - gh => - vault - .auth() - .loginByGithub(gh.token.value(), gh.mount) - .getAuthClientToken) - - case _ => - throw new ConnectException( - s"Unsupported auth method [${settings.authMode.toString}]") - } - - config.token(token.get) - config.build() - new Vault(config) - } - - // set up tls - private def configureSSL(settings: VaultSettings): SslConfig = { - val ssl = new SslConfig() - - if (settings.keystoreLoc != "") { - logger.info(s"Configuring keystore at [${settings.keystoreLoc}]") - ssl.keyStoreFile( - new File(settings.keystoreLoc), - settings.keystorePass.value() - ) - } - - if (settings.truststoreLoc != "") { - logger.info(s"Configuring keystore at [${settings.truststoreLoc}]") - ssl.trustStoreFile(new File(settings.truststoreLoc)) - } - - if (settings.clientPem != "") { - logger.info(s"Configuring client PEM. Ignored if JKS set.") - ssl.clientKeyPemFile(new File(settings.clientPem)) - } - - if (settings.pem != "") { - logger.info(s"Configuring Vault Server PEM. Ignored if JKS set.") - ssl.pemFile(new File(settings.pem)) - } - - ssl.build() - } -} +/* + * + * * Copyright 2017-2020 Lenses.io Ltd + * + */ + +package io.lenses.connect.secrets.providers + +import com.bettercloud.vault.{SslConfig, Vault, VaultConfig} +import com.typesafe.scalalogging.StrictLogging +import io.lenses.connect.secrets.config.{VaultAuthMethod, VaultSettings} +import org.apache.kafka.connect.errors.ConnectException + +import java.io.File + +trait VaultHelper extends StrictLogging { + + // initialize the vault client + def createClient(settings: VaultSettings): Vault = { + val config = + new VaultConfig().address(settings.addr) + + // set ssl if configured + config.sslConfig(configureSSL(settings)) + + if (settings.namespace.nonEmpty) { + logger.info(s"Setting namespace to ${settings.namespace}") + config.nameSpace(settings.namespace) + } + + logger.info(s"Setting engine version to ${settings.engineVersion}") + config.engineVersion(settings.engineVersion) + + val vault = new Vault(config.build()) + + logger.info( + s"Initializing client with mode [${settings.authMode.toString}]" + ) + + val token = settings.authMode match { + case VaultAuthMethod.USERPASS => + settings.userPass + .map(up => + vault + .auth() + .loginByUserPass(up.username, up.password.value(), up.mount) + .getAuthClientToken + ) + + case VaultAuthMethod.APPROLE => + settings.appRole + .map(ar => + vault + .auth() + .loginByAppRole(ar.role, ar.secretId.value()) + .getAuthClientToken + ) + + case VaultAuthMethod.CERT => + settings.cert + .map(c => vault.auth().loginByCert(c.mount).getAuthClientToken) + + case VaultAuthMethod.AWSIAM => + settings.awsIam + .map(aws => + vault + .auth() + .loginByAwsIam( + aws.role, + aws.url, + aws.body.value(), + aws.headers.value(), + aws.mount + ) + .getAuthClientToken + ) + + case VaultAuthMethod.KUBERNETES => + settings.k8s + .map(k8s => + vault + .auth() + .loginByKubernetes(k8s.role, k8s.jwt.value()) + .getAuthClientToken + ) + case VaultAuthMethod.GCP => + settings.gcp + .map(gcp => + vault + .auth() + .loginByGCP(gcp.role, gcp.jwt.value()) + .getAuthClientToken + ) + + case VaultAuthMethod.LDAP => + settings.ldap + .map(l => + vault + .auth() + .loginByLDAP(l.username, l.password.value(), l.mount) + .getAuthClientToken + ) + + case VaultAuthMethod.JWT => + settings.jwt + .map(j => + vault + .auth() + .loginByJwt(j.provider, j.role, j.jwt.value()) + .getAuthClientToken + ) + + case VaultAuthMethod.TOKEN => + Some(settings.token.value()) + + case VaultAuthMethod.GITHUB => + settings.github + .map(gh => + vault + .auth() + .loginByGithub(gh.token.value(), gh.mount) + .getAuthClientToken + ) + + case _ => + throw new ConnectException( + s"Unsupported auth method [${settings.authMode.toString}]" + ) + } + + config.token(token.get) + config.build() + new Vault(config) + } + + // set up tls + private def configureSSL(settings: VaultSettings): SslConfig = { + val ssl = new SslConfig() + + if (settings.keystoreLoc != "") { + logger.info(s"Configuring keystore at [${settings.keystoreLoc}]") + ssl.keyStoreFile( + new File(settings.keystoreLoc), + settings.keystorePass.value() + ) + } + + if (settings.truststoreLoc != "") { + logger.info(s"Configuring keystore at [${settings.truststoreLoc}]") + ssl.trustStoreFile(new File(settings.truststoreLoc)) + } + + if (settings.clientPem != "") { + logger.info(s"Configuring client PEM. Ignored if JKS set.") + ssl.clientKeyPemFile(new File(settings.clientPem)) + } + + if (settings.pem != "") { + logger.info(s"Configuring Vault Server PEM. Ignored if JKS set.") + ssl.pemFile(new File(settings.pem)) + } + + ssl.build() + } +} diff --git a/src/main/scala/io/lenses/connect/secrets/providers/VaultSecretProvider.scala b/src/main/scala/io/lenses/connect/secrets/providers/VaultSecretProvider.scala index 685d2bd..44f2827 100644 --- a/src/main/scala/io/lenses/connect/secrets/providers/VaultSecretProvider.scala +++ b/src/main/scala/io/lenses/connect/secrets/providers/VaultSecretProvider.scala @@ -28,14 +28,18 @@ class VaultSecretProvider() extends ConfigProvider with VaultHelper { private var settings: VaultSettings = _ private var vaultClient: Option[Vault] = None private var tokenRenewal: Option[AsyncFunctionLoop] = None - private val cache = mutable.Map.empty[String, (Option[OffsetDateTime], ConfigData)] + private val cache = + mutable.Map.empty[String, (Option[OffsetDateTime], ConfigData)] def getClient: Option[Vault] = vaultClient // configure the vault client override def configure(configs: util.Map[String, _]): Unit = { settings = VaultSettings(VaultProviderConfig(configs)) vaultClient = Some(createClient(settings)) - val renewalLoop = new AsyncFunctionLoop(settings.tokenRenewal, "Vault Token Renewal")(renewToken()) + val renewalLoop = + new AsyncFunctionLoop(settings.tokenRenewal, "Vault Token Renewal")( + renewToken() + ) tokenRenewal = Some(renewalLoop) renewalLoop.start() } @@ -44,9 +48,7 @@ class VaultSecretProvider() extends ConfigProvider with VaultHelper { def tokenRenewalFailure: Long = tokenRenewal.map(_.failureRate).getOrElse(-1) private def renewToken(): Unit = { - vaultClient.foreach { client => - client.auth().renewSelf() - } + vaultClient.foreach { client => client.auth().renewSelf() } } // lookup secrets at a path @@ -69,7 +71,8 @@ class VaultSecretProvider() extends ConfigProvider with VaultHelper { } expiry.foreach(exp => - logger.info(s"Min expiry for TTL set to [${exp.toString}]")) + logger.info(s"Min expiry for TTL set to [${exp.toString}]") + ) cache += (path -> (expiry, data)) data } @@ -83,31 +86,40 @@ class VaultSecretProvider() extends ConfigProvider with VaultHelper { val now = OffsetDateTime.now() if (keys.asScala.subsetOf(data.data().asScala.keySet) && expiresAt - .getOrElse(now.plusSeconds(1)) - .isAfter(now)) { + .getOrElse(now.plusSeconds(1)) + .isAfter(now)) { logger.info("Fetching secrets from cache") - (expiresAt, + ( + expiresAt, new ConfigData( - data.data().asScala.view.filter{ - case (k,_) => keys.contains(k) - }.toMap.asJava, - data.ttl())) + data + .data() + .asScala + .view + .filter { + case (k, _) => keys.contains(k) + } + .toMap + .asJava, + data.ttl() + ) + ) } else { // missing some or expired so reload - getSecretsAndExpiry( - getSecrets(path).view.filter{ - case (k,_) => keys.contains(k) - }.toMap) + getSecretsAndExpiry(getSecrets(path).view.filter { + case (k, _) => keys.contains(k) + }.toMap) } case None => - getSecretsAndExpiry(getSecrets(path).view.filter{ - case (k,_) => keys.contains(k) + getSecretsAndExpiry(getSecrets(path).view.filter { + case (k, _) => keys.contains(k) }.toMap) } expiry.foreach(exp => - logger.info(s"Min expiry for TTL set to [${exp.toString}]")) + logger.info(s"Min expiry for TTL set to [${exp.toString}]") + ) cache += (path -> (expiry, data)) data } @@ -117,7 +129,9 @@ class VaultSecretProvider() extends ConfigProvider with VaultHelper { } // get the secrets and ttl under a path - def getSecrets(path: String): Map[String, (String, Option[OffsetDateTime])] = { + def getSecrets( + path: String + ): Map[String, (String, Option[OffsetDateTime])] = { val now = OffsetDateTime.now() logger.info(s"Looking up value at [$path]") @@ -126,16 +140,13 @@ class VaultSecretProvider() extends ConfigProvider with VaultHelper { case Success(response) => if (response.getRestResponse.getStatus != 200) { throw new ConnectException( - s"No secrets found at path [$path]. Vault response: ${ - new String( - response.getRestResponse.getBody) - }" + s"No secrets found at path [$path]. Vault response: ${new String(response.getRestResponse.getBody)}" ) } val ttl = Option(vaultClient.get.logical().read(path).getLeaseDuration) match { case Some(duration) => Some(now.plusSeconds(duration)) - case None => None + case None => None } if (response.getData.isEmpty) { @@ -148,9 +159,14 @@ class VaultSecretProvider() extends ConfigProvider with VaultHelper { case (k, v) => val encodingAndId = EncodingAndId.from(k) val decoded = - decodeKey(encoding = encodingAndId.encoding, key = k, value = v, writeFileFn = { content => - fileWriter.write(k.toLowerCase, content, k).toString - }) + decodeKey( + encoding = encodingAndId.encoding, + key = k, + value = v, + writeFileFn = { content => + fileWriter.write(k.toLowerCase, content, k).toString + } + ) (k, (decoded, ttl)) }.toMap diff --git a/src/main/scala/io/lenses/connect/secrets/utils/WithRetry.scala b/src/main/scala/io/lenses/connect/secrets/utils/WithRetry.scala index b937d8b..1ac541b 100644 --- a/src/main/scala/io/lenses/connect/secrets/utils/WithRetry.scala +++ b/src/main/scala/io/lenses/connect/secrets/utils/WithRetry.scala @@ -12,11 +12,15 @@ import scala.util.{Failure, Success, Try} trait WithRetry { @tailrec - protected final def withRetry[T](retry: Int = 5, interval:Option[FiniteDuration])(thunk: => T): T = + protected final def withRetry[T]( + retry: Int = 5, + interval: Option[FiniteDuration] + )(thunk: => T): T = Try { thunk } match { - case Failure(t) => if (retry == 0) throw t + case Failure(t) => + if (retry == 0) throw t interval.foreach(sleepValue => Thread.sleep(sleepValue.toMillis)) withRetry(retry - 1, interval)(thunk) case Success(value) => value diff --git a/src/test/java/io/lenses/connect/secrets/vault/MockVault.java b/src/test/java/io/lenses/connect/secrets/vault/MockVault.java index e587c09..2968960 100644 --- a/src/test/java/io/lenses/connect/secrets/vault/MockVault.java +++ b/src/test/java/io/lenses/connect/secrets/vault/MockVault.java @@ -10,9 +10,9 @@ import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.handler.AbstractHandler; -import javax.servlet.ServletException; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.Map; import java.util.Optional; diff --git a/src/test/java/io/lenses/connect/secrets/vault/VaultTestUtils.java b/src/test/java/io/lenses/connect/secrets/vault/VaultTestUtils.java index 0d8268d..b788875 100644 --- a/src/test/java/io/lenses/connect/secrets/vault/VaultTestUtils.java +++ b/src/test/java/io/lenses/connect/secrets/vault/VaultTestUtils.java @@ -12,7 +12,7 @@ import org.eclipse.jetty.server.*; import org.eclipse.jetty.util.ssl.SslContextFactory; -import javax.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequest; import java.io.IOException; import java.util.Collections; import java.util.Map; diff --git a/src/test/scala/io/lenses/connect/secrets/TmpDirUtil.scala b/src/test/scala/io/lenses/connect/secrets/TmpDirUtil.scala new file mode 100644 index 0000000..68d45a3 --- /dev/null +++ b/src/test/scala/io/lenses/connect/secrets/TmpDirUtil.scala @@ -0,0 +1,11 @@ +package io.lenses.connect.secrets + +import java.nio.file.FileSystems + +object TmpDirUtil { + + val separator: String = FileSystems.getDefault.getSeparator + + def getTempDir: String = + System.getProperty("java.io.tmpdir").stripSuffix(separator) +} diff --git a/src/test/scala/io/lenses/connect/secrets/io/FileWriterOnceTest.scala b/src/test/scala/io/lenses/connect/secrets/io/FileWriterOnceTest.scala index 22d09c9..e86c306 100644 --- a/src/test/scala/io/lenses/connect/secrets/io/FileWriterOnceTest.scala +++ b/src/test/scala/io/lenses/connect/secrets/io/FileWriterOnceTest.scala @@ -45,7 +45,7 @@ class FileWriterOnceTest Using(Source.fromFile(file))(_.size) shouldBe Success(content1.length) - val content2 = Array(1, 2, 3,4,5,6,7,8).map(_.toByte) + val content2 = Array(1, 2, 3, 4, 5, 6, 7, 8).map(_.toByte) writer.write(fileName, content2, "key1") Using(Source.fromFile(file))(_.size) shouldBe Success(content1.length) diff --git a/src/test/scala/io/lenses/connect/secrets/providers/AWSSecretProviderTest.scala b/src/test/scala/io/lenses/connect/secrets/providers/AWSSecretProviderTest.scala index 8475778..a568daf 100644 --- a/src/test/scala/io/lenses/connect/secrets/providers/AWSSecretProviderTest.scala +++ b/src/test/scala/io/lenses/connect/secrets/providers/AWSSecretProviderTest.scala @@ -1,331 +1,336 @@ -/* - * - * * Copyright 2017-2020 Lenses.io Ltd - * - */ - -package io.lenses.connect.secrets.providers - -import com.amazonaws.services.secretsmanager.AWSSecretsManager -import com.amazonaws.services.secretsmanager.model._ -import com.bettercloud.vault.json.JsonObject -import io.lenses.connect.secrets.config.{AWSProviderConfig, AWSProviderSettings} -import io.lenses.connect.secrets.connect.{AuthMode, Encoding, _} -import io.lenses.connect.secrets.utils.EncodingAndId -import org.apache.kafka.common.config.ConfigTransformer -import org.apache.kafka.common.config.provider.ConfigProvider -import org.apache.kafka.connect.errors.ConnectException -import org.mockito.ArgumentMatchers.any -import org.mockito.MockitoSugar -import org.scalatest.matchers.should.Matchers -import org.scalatest.wordspec.AnyWordSpec - -import java.nio.file.FileSystems -import java.util.{Base64, Date} -import scala.io.Source -import scala.jdk.CollectionConverters._ -import scala.util.{Success, Using} - -class AWSSecretProviderTest - extends AnyWordSpec - with Matchers - with MockitoSugar { - - val separator: String = FileSystems.getDefault.getSeparator - val tmp: String = - System.getProperty("java.io.tmpdir") + separator + "provider-tests-aws" - - "should authenticate with credentials" in { - val props = Map( - AWSProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, - AWSProviderConfig.AWS_ACCESS_KEY -> "somekey", - AWSProviderConfig.AWS_SECRET_KEY -> "secretkey", - AWSProviderConfig.AWS_REGION -> "someregion" - ).asJava - - val provider = new AWSSecretProvider() - provider.configure(props) - provider.close() - } - - "should authenticate with credentials and lookup a secret" in { - val props = Map( - AWSProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, - AWSProviderConfig.AWS_ACCESS_KEY -> "somekey", - AWSProviderConfig.AWS_SECRET_KEY -> "secretkey", - AWSProviderConfig.AWS_REGION -> "someregion" - ).asJava - - val secretKey = "my-secret-key" - val secretName = "my-secret-name" - val secretValue = "secret-value" - - val provider = new AWSSecretProvider() - provider.configure(props) - - val mockClient = mock[AWSSecretsManager] - val secretValRequest = - new GetSecretValueRequest().withSecretId(secretName) - val secretValResponse = new GetSecretValueResult() - val secretJson = new JsonObject().add(secretKey, secretValue) - secretValResponse.setName(secretName) - secretValResponse.setSecretString(secretJson.toString()) - - val now = new Date() - val describeSecretResponse = new DescribeSecretResult() - describeSecretResponse.setLastRotatedDate(now) - describeSecretResponse.setRotationEnabled(true) - describeSecretResponse.setLastRotatedDate(now) - - val rotationRulesType = new RotationRulesType() - rotationRulesType.setAutomaticallyAfterDays(1.toLong) - describeSecretResponse.setRotationRules(rotationRulesType) - - when(mockClient.describeSecret(any[DescribeSecretRequest])) - .thenReturn(describeSecretResponse) - when(mockClient.getSecretValue(secretValRequest)) - .thenReturn(secretValResponse) - - provider.client = Some(mockClient) - val data = provider.get(secretName, Set(secretKey).asJava) - data.data().get(secretKey) shouldBe secretValue - provider.close() - } - - "should authenticate with credentials and lookup a base64 secret" in { - val props = Map( - AWSProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, - AWSProviderConfig.AWS_ACCESS_KEY -> "somekey", - AWSProviderConfig.AWS_SECRET_KEY -> "secretkey", - AWSProviderConfig.AWS_REGION -> "someregion" - ).asJava - - val secretKey = Encoding.BASE64.toString - val secretName = "my-secret-name" - val secretValue = "base64-secret-value" - - val provider = new AWSSecretProvider() - provider.configure(props) - - val mockClient = mock[AWSSecretsManager] - val secretValRequest = - new GetSecretValueRequest().withSecretId(secretName) - val secretValResponse = new GetSecretValueResult() - secretValResponse.setName(secretName) - val secretJson = new JsonObject().add( - secretKey, - Base64.getEncoder.encodeToString(secretValue.getBytes) - ) - secretValResponse.setSecretString(secretJson.toString) - - val now = new Date() - val describeSecretResponse = new DescribeSecretResult() - describeSecretResponse.setLastRotatedDate(now) - describeSecretResponse.setRotationEnabled(true) - describeSecretResponse.setLastRotatedDate(now) - - val rotationRulesType = new RotationRulesType() - rotationRulesType.setAutomaticallyAfterDays(1.toLong) - describeSecretResponse.setRotationRules(rotationRulesType) - - when(mockClient.describeSecret(any[DescribeSecretRequest])) - .thenReturn(describeSecretResponse) - when(mockClient.getSecretValue(secretValRequest)) - .thenReturn(secretValResponse) - - provider.client = Some(mockClient) - val data = provider.get(secretName, Set(secretKey).asJava) - data.data().get(secretKey) shouldBe secretValue - - provider.get("").data().isEmpty shouldBe true - provider.close() - } - - "should authenticate with credentials and lookup a base64 secret and write to file" in { - val props = Map( - AWSProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, - AWSProviderConfig.AWS_ACCESS_KEY -> "somekey", - AWSProviderConfig.AWS_SECRET_KEY -> "secretkey", - AWSProviderConfig.AWS_REGION -> "someregion", - FILE_DIR -> tmp - ).asJava - - val secretKey = Encoding.BASE64_FILE.toString - val secretName = "my-secret-name" - val secretValue = "base64-secret-value" - - val provider = new AWSSecretProvider() - provider.configure(props) - - val mockClient = mock[AWSSecretsManager] - val secretValRequest = - new GetSecretValueRequest().withSecretId(secretName) - val secretValResponse = new GetSecretValueResult() - secretValResponse.setName(secretName) - val secretJson = new JsonObject().add( - secretKey, - Base64.getEncoder.encodeToString("base64-secret-value".getBytes) - ) - secretValResponse.setSecretString(secretJson.toString) - - val now = new Date() - val describeSecretResponse = new DescribeSecretResult() - describeSecretResponse.setLastRotatedDate(now) - describeSecretResponse.setRotationEnabled(true) - describeSecretResponse.setLastRotatedDate(now) - - val rotationRulesType = new RotationRulesType() - rotationRulesType.setAutomaticallyAfterDays(1.toLong) - describeSecretResponse.setRotationRules(rotationRulesType) - - when(mockClient.describeSecret(any[DescribeSecretRequest])) - .thenReturn(describeSecretResponse) - when(mockClient.getSecretValue(secretValRequest)) - .thenReturn(secretValResponse) - - provider.client = Some(mockClient) - val data = provider.get(secretName, Set(secretKey).asJava) - val outputFile = data.data().get(secretKey) - outputFile shouldBe s"$tmp$separator$secretName$separator${secretKey.toLowerCase}" - - Using(Source.fromFile(outputFile))(_.getLines().mkString) shouldBe Success(secretValue) - - provider.get("").data().isEmpty shouldBe true - provider.close() - } - - "should authenticate with credentials and lookup a utf8 secret and write to file" in { - val props = Map( - AWSProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, - AWSProviderConfig.AWS_ACCESS_KEY -> "somekey", - AWSProviderConfig.AWS_SECRET_KEY -> "secretkey", - AWSProviderConfig.AWS_REGION -> "someregion", - FILE_DIR -> tmp - ).asJava - - val secretKey = - s"${Encoding.UTF8_FILE}${EncodingAndId.Separator}my-secret-key" - val secretName = "my-secret-name" - val secretValue = "utf8-secret-value" - - val provider = new AWSSecretProvider() - provider.configure(props) - - val mockClient = mock[AWSSecretsManager] - val secretValRequest = - new GetSecretValueRequest().withSecretId(secretName) - val secretValResponse = new GetSecretValueResult() - secretValResponse.setName(secretName) - val secretJson = new JsonObject().add( - secretKey, - secretValue - ) - secretValResponse.setSecretString(secretJson.toString) - - val now = new Date() - val describeSecretResponse = new DescribeSecretResult() - describeSecretResponse.setLastRotatedDate(now) - describeSecretResponse.setRotationEnabled(true) - describeSecretResponse.setLastRotatedDate(now) - - val rotationRulesType = new RotationRulesType() - rotationRulesType.setAutomaticallyAfterDays(1.toLong) - describeSecretResponse.setRotationRules(rotationRulesType) - - when(mockClient.describeSecret(any[DescribeSecretRequest])) - .thenReturn(describeSecretResponse) - when(mockClient.getSecretValue(secretValRequest)) - .thenReturn(secretValResponse) - - provider.client = Some(mockClient) - val data = provider.get(secretName, Set(secretKey).asJava) - val outputFile = data.data().get(secretKey) - outputFile shouldBe s"$tmp$separator$secretName$separator${secretKey.toLowerCase}" - - Using(Source.fromFile(outputFile))(_.getLines().mkString) shouldBe Success(secretValue) - - provider.get("").data().isEmpty shouldBe true - provider.close() - } - - "should throw an exception if access key not set and not default auth mode" in { - - intercept[ConnectException] { - AWSProviderSettings( - AWSProviderConfig( - Map( - AWSProviderConfig.AWS_REGION -> "someregion", - AWSProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, - AWSProviderConfig.AWS_SECRET_KEY -> "secretId" - ).asJava - ) - ) - } - } - - "should throw an exception if secret key not set and not default auth mode" in { - - intercept[ConnectException] { - AWSProviderSettings( - AWSProviderConfig( - Map( - AWSProviderConfig.AWS_REGION -> "someregion", - AWSProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, - AWSProviderConfig.AWS_ACCESS_KEY -> "someclientid" - ).asJava - ) - ) - } - } - - "should check transformer" in { - - val secretKey = s"my-secret-key" - val secretName = "my-secret-name" - val secretValue = "utf8-secret-value" - - val mockClient = mock[AWSSecretsManager] - val secretValRequest = - new GetSecretValueRequest().withSecretId(secretName) - val secretValResponse = new GetSecretValueResult() - val secretJson = new JsonObject().add(secretKey, secretValue) - secretValResponse.setName(secretName) - secretValResponse.setSecretString(secretJson.toString()) - - val now = new Date() - val describeSecretResponse = new DescribeSecretResult() - describeSecretResponse.setLastRotatedDate(now) - describeSecretResponse.setRotationEnabled(true) - describeSecretResponse.setLastRotatedDate(now) - - val rotationRulesType = new RotationRulesType() - rotationRulesType.setAutomaticallyAfterDays(1.toLong) - describeSecretResponse.setRotationRules(rotationRulesType) - - when(mockClient.describeSecret(any[DescribeSecretRequest])) - .thenReturn(describeSecretResponse) - when(mockClient.getSecretValue(secretValRequest)) - .thenReturn(secretValResponse) - - val props = Map( - AWSProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, - AWSProviderConfig.AWS_ACCESS_KEY -> "somekey", - AWSProviderConfig.AWS_SECRET_KEY -> "secretkey", - AWSProviderConfig.AWS_REGION -> "someregion" - ).asJava - - val provider = new AWSSecretProvider() - provider.configure(props) - provider.client = Some(mockClient) - - // check the workerconfigprovider - val map = new java.util.HashMap[String, ConfigProvider]() - map.put("aws", provider) - val transformer = new ConfigTransformer(map) - val props2 = - Map("mykey" -> "${aws:my-secret-name:my-secret-key}").asJava - val data = transformer.transform(props2) - data.data().get("mykey") shouldBe secretValue - provider.close() - } -} +/* + * + * * Copyright 2017-2020 Lenses.io Ltd + * + */ + +package io.lenses.connect.secrets.providers + +import com.amazonaws.services.secretsmanager.AWSSecretsManager +import com.amazonaws.services.secretsmanager.model._ +import com.bettercloud.vault.json.JsonObject +import io.lenses.connect.secrets.TmpDirUtil.getTempDir +import io.lenses.connect.secrets.config.{AWSProviderConfig, AWSProviderSettings} +import io.lenses.connect.secrets.connect.{AuthMode, Encoding, _} +import io.lenses.connect.secrets.utils.EncodingAndId +import org.apache.kafka.common.config.ConfigTransformer +import org.apache.kafka.common.config.provider.ConfigProvider +import org.apache.kafka.connect.errors.ConnectException +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.when +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec +import org.scalatestplus.mockito.MockitoSugar + +import java.nio.file.FileSystems +import java.util.{Base64, Date} +import scala.io.Source +import scala.jdk.CollectionConverters._ +import scala.util.{Success, Using} + +class AWSSecretProviderTest + extends AnyWordSpec + with Matchers + with MockitoSugar { + + val separator: String = FileSystems.getDefault.getSeparator + val tmp: String = s"$getTempDir${separator}provider-tests-aws" + + "should authenticate with credentials" in { + val props = Map( + AWSProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, + AWSProviderConfig.AWS_ACCESS_KEY -> "somekey", + AWSProviderConfig.AWS_SECRET_KEY -> "secretkey", + AWSProviderConfig.AWS_REGION -> "someregion" + ).asJava + + val provider = new AWSSecretProvider() + provider.configure(props) + provider.close() + } + + "should authenticate with credentials and lookup a secret" in { + val props = Map( + AWSProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, + AWSProviderConfig.AWS_ACCESS_KEY -> "somekey", + AWSProviderConfig.AWS_SECRET_KEY -> "secretkey", + AWSProviderConfig.AWS_REGION -> "someregion" + ).asJava + + val secretKey = "my-secret-key" + val secretName = "my-secret-name" + val secretValue = "secret-value" + + val provider = new AWSSecretProvider() + provider.configure(props) + + val mockClient = mock[AWSSecretsManager] + val secretValRequest = + new GetSecretValueRequest().withSecretId(secretName) + val secretValResponse = new GetSecretValueResult() + val secretJson = new JsonObject().add(secretKey, secretValue) + secretValResponse.setName(secretName) + secretValResponse.setSecretString(secretJson.toString()) + + val now = new Date() + val describeSecretResponse = new DescribeSecretResult() + describeSecretResponse.setLastRotatedDate(now) + describeSecretResponse.setRotationEnabled(true) + describeSecretResponse.setLastRotatedDate(now) + + val rotationRulesType = new RotationRulesType() + rotationRulesType.setAutomaticallyAfterDays(1.toLong) + describeSecretResponse.setRotationRules(rotationRulesType) + + when(mockClient.describeSecret(any[DescribeSecretRequest])) + .thenReturn(describeSecretResponse) + when(mockClient.getSecretValue(secretValRequest)) + .thenReturn(secretValResponse) + + provider.client = Some(mockClient) + val data = provider.get(secretName, Set(secretKey).asJava) + data.data().get(secretKey) shouldBe secretValue + provider.close() + } + + "should authenticate with credentials and lookup a base64 secret" in { + val props = Map( + AWSProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, + AWSProviderConfig.AWS_ACCESS_KEY -> "somekey", + AWSProviderConfig.AWS_SECRET_KEY -> "secretkey", + AWSProviderConfig.AWS_REGION -> "someregion" + ).asJava + + val secretKey = Encoding.BASE64.toString + val secretName = "my-secret-name" + val secretValue = "base64-secret-value" + + val provider = new AWSSecretProvider() + provider.configure(props) + + val mockClient = mock[AWSSecretsManager] + val secretValRequest = + new GetSecretValueRequest().withSecretId(secretName) + val secretValResponse = new GetSecretValueResult() + secretValResponse.setName(secretName) + val secretJson = new JsonObject().add( + secretKey, + Base64.getEncoder.encodeToString(secretValue.getBytes) + ) + secretValResponse.setSecretString(secretJson.toString) + + val now = new Date() + val describeSecretResponse = new DescribeSecretResult() + describeSecretResponse.setLastRotatedDate(now) + describeSecretResponse.setRotationEnabled(true) + describeSecretResponse.setLastRotatedDate(now) + + val rotationRulesType = new RotationRulesType() + rotationRulesType.setAutomaticallyAfterDays(1.toLong) + describeSecretResponse.setRotationRules(rotationRulesType) + + when(mockClient.describeSecret(any[DescribeSecretRequest])) + .thenReturn(describeSecretResponse) + when(mockClient.getSecretValue(secretValRequest)) + .thenReturn(secretValResponse) + + provider.client = Some(mockClient) + val data = provider.get(secretName, Set(secretKey).asJava) + data.data().get(secretKey) shouldBe secretValue + + provider.get("").data().isEmpty shouldBe true + provider.close() + } + + "should authenticate with credentials and lookup a base64 secret and write to file" in { + val props = Map( + AWSProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, + AWSProviderConfig.AWS_ACCESS_KEY -> "somekey", + AWSProviderConfig.AWS_SECRET_KEY -> "secretkey", + AWSProviderConfig.AWS_REGION -> "someregion", + FILE_DIR -> tmp + ).asJava + + val secretKey = Encoding.BASE64_FILE.toString + val secretName = "my-secret-name" + val secretValue = "base64-secret-value" + + val provider = new AWSSecretProvider() + provider.configure(props) + + val mockClient = mock[AWSSecretsManager] + val secretValRequest = + new GetSecretValueRequest().withSecretId(secretName) + val secretValResponse = new GetSecretValueResult() + secretValResponse.setName(secretName) + val secretJson = new JsonObject().add( + secretKey, + Base64.getEncoder.encodeToString("base64-secret-value".getBytes) + ) + secretValResponse.setSecretString(secretJson.toString) + + val now = new Date() + val describeSecretResponse = new DescribeSecretResult() + describeSecretResponse.setLastRotatedDate(now) + describeSecretResponse.setRotationEnabled(true) + describeSecretResponse.setLastRotatedDate(now) + + val rotationRulesType = new RotationRulesType() + rotationRulesType.setAutomaticallyAfterDays(1.toLong) + describeSecretResponse.setRotationRules(rotationRulesType) + + when(mockClient.describeSecret(any[DescribeSecretRequest])) + .thenReturn(describeSecretResponse) + when(mockClient.getSecretValue(secretValRequest)) + .thenReturn(secretValResponse) + + provider.client = Some(mockClient) + val data = provider.get(secretName, Set(secretKey).asJava) + val outputFile = data.data().get(secretKey) + outputFile shouldBe s"$tmp$separator$secretName$separator${secretKey.toLowerCase}" + + Using(Source.fromFile(outputFile))(_.getLines().mkString) shouldBe Success( + secretValue + ) + + provider.get("").data().isEmpty shouldBe true + provider.close() + } + + "should authenticate with credentials and lookup a utf8 secret and write to file" in { + val props = Map( + AWSProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, + AWSProviderConfig.AWS_ACCESS_KEY -> "somekey", + AWSProviderConfig.AWS_SECRET_KEY -> "secretkey", + AWSProviderConfig.AWS_REGION -> "someregion", + FILE_DIR -> tmp + ).asJava + + val secretKey = + s"${Encoding.UTF8_FILE}${EncodingAndId.Separator}my-secret-key" + val secretName = "my-secret-name" + val secretValue = "utf8-secret-value" + + val provider = new AWSSecretProvider() + provider.configure(props) + + val mockClient = mock[AWSSecretsManager] + val secretValRequest = + new GetSecretValueRequest().withSecretId(secretName) + val secretValResponse = new GetSecretValueResult() + secretValResponse.setName(secretName) + val secretJson = new JsonObject().add( + secretKey, + secretValue + ) + secretValResponse.setSecretString(secretJson.toString) + + val now = new Date() + val describeSecretResponse = new DescribeSecretResult() + describeSecretResponse.setLastRotatedDate(now) + describeSecretResponse.setRotationEnabled(true) + describeSecretResponse.setLastRotatedDate(now) + + val rotationRulesType = new RotationRulesType() + rotationRulesType.setAutomaticallyAfterDays(1.toLong) + describeSecretResponse.setRotationRules(rotationRulesType) + + when(mockClient.describeSecret(any[DescribeSecretRequest])) + .thenReturn(describeSecretResponse) + when(mockClient.getSecretValue(secretValRequest)) + .thenReturn(secretValResponse) + + provider.client = Some(mockClient) + val data = provider.get(secretName, Set(secretKey).asJava) + val outputFile = data.data().get(secretKey) + outputFile shouldBe s"$tmp$separator$secretName$separator${secretKey.toLowerCase}" + + Using(Source.fromFile(outputFile))(_.getLines().mkString) shouldBe Success( + secretValue + ) + + provider.get("").data().isEmpty shouldBe true + provider.close() + } + + "should throw an exception if access key not set and not default auth mode" in { + + intercept[ConnectException] { + AWSProviderSettings( + AWSProviderConfig( + Map( + AWSProviderConfig.AWS_REGION -> "someregion", + AWSProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, + AWSProviderConfig.AWS_SECRET_KEY -> "secretId" + ).asJava + ) + ) + } + } + + "should throw an exception if secret key not set and not default auth mode" in { + + intercept[ConnectException] { + AWSProviderSettings( + AWSProviderConfig( + Map( + AWSProviderConfig.AWS_REGION -> "someregion", + AWSProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, + AWSProviderConfig.AWS_ACCESS_KEY -> "someclientid" + ).asJava + ) + ) + } + } + + "should check transformer" in { + + val secretKey = s"my-secret-key" + val secretName = "my-secret-name" + val secretValue = "utf8-secret-value" + + val mockClient = mock[AWSSecretsManager] + val secretValRequest = + new GetSecretValueRequest().withSecretId(secretName) + val secretValResponse = new GetSecretValueResult() + val secretJson = new JsonObject().add(secretKey, secretValue) + secretValResponse.setName(secretName) + secretValResponse.setSecretString(secretJson.toString()) + + val now = new Date() + val describeSecretResponse = new DescribeSecretResult() + describeSecretResponse.setLastRotatedDate(now) + describeSecretResponse.setRotationEnabled(true) + describeSecretResponse.setLastRotatedDate(now) + + val rotationRulesType = new RotationRulesType() + rotationRulesType.setAutomaticallyAfterDays(1.toLong) + describeSecretResponse.setRotationRules(rotationRulesType) + + when(mockClient.describeSecret(any[DescribeSecretRequest])) + .thenReturn(describeSecretResponse) + when(mockClient.getSecretValue(secretValRequest)) + .thenReturn(secretValResponse) + + val props = Map( + AWSProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, + AWSProviderConfig.AWS_ACCESS_KEY -> "somekey", + AWSProviderConfig.AWS_SECRET_KEY -> "secretkey", + AWSProviderConfig.AWS_REGION -> "someregion" + ).asJava + + val provider = new AWSSecretProvider() + provider.configure(props) + provider.client = Some(mockClient) + + // check the workerconfigprovider + val map = new java.util.HashMap[String, ConfigProvider]() + map.put("aws", provider) + val transformer = new ConfigTransformer(map) + val props2 = + Map("mykey" -> "${aws:my-secret-name:my-secret-key}").asJava + val data = transformer.transform(props2) + data.data().get("mykey") shouldBe secretValue + provider.close() + } +} diff --git a/src/test/scala/io/lenses/connect/secrets/providers/Aes256DecodingHelperTest.scala b/src/test/scala/io/lenses/connect/secrets/providers/Aes256DecodingHelperTest.scala index 40e4408..4e313de 100644 --- a/src/test/scala/io/lenses/connect/secrets/providers/Aes256DecodingHelperTest.scala +++ b/src/test/scala/io/lenses/connect/secrets/providers/Aes256DecodingHelperTest.scala @@ -13,8 +13,8 @@ class Aes256DecodingHelperTest extends AnyWordSpec with Matchers with TableDrivenPropertyChecks { - - import AesDecodingTestHelper.encrypt + + import AesDecodingTestHelper.encrypt "AES-256 decorer" should { "not be created for invalid key length" in { @@ -54,7 +54,7 @@ class Aes256DecodingHelperTest } trait TestContext { - val key = generateKey() + val key = generateKey() def generateKey(): String = randomUUID.toString.take(32) diff --git a/src/test/scala/io/lenses/connect/secrets/providers/Aes256DecodingProviderTest.scala b/src/test/scala/io/lenses/connect/secrets/providers/Aes256DecodingProviderTest.scala index 561bfea..7f8e187 100644 --- a/src/test/scala/io/lenses/connect/secrets/providers/Aes256DecodingProviderTest.scala +++ b/src/test/scala/io/lenses/connect/secrets/providers/Aes256DecodingProviderTest.scala @@ -26,42 +26,60 @@ class Aes256DecodingProviderTest import AesDecodingTestHelper.encrypt "aes256 provider" should { - "decrypt aes 256 utf-8 encoded value" in new TestContext with ConfiguredProvider { + "decrypt aes 256 utf-8 encoded value" in new TestContext + with ConfiguredProvider { val encrypted = encrypt(value, key) forAll(Table("encoding", "", "utf-8")) { encoding => - val decrypted = provider.get(encoding, Set(encrypted).asJava).data().asScala + val decrypted = + provider.get(encoding, Set(encrypted).asJava).data().asScala decrypted.get(encrypted) shouldBe Some(value) } } - "decrypt aes 256 base64 encoded value" in new TestContext with ConfiguredProvider { - val encrypted = encrypt(Base64.getEncoder.encodeToString(value.getBytes()), key) + "decrypt aes 256 base64 encoded value" in new TestContext + with ConfiguredProvider { + val encrypted = + encrypt(Base64.getEncoder.encodeToString(value.getBytes()), key) - val decrypted = provider.get("base64", Set(encrypted).asJava).data().asScala + val decrypted = + provider.get("base64", Set(encrypted).asJava).data().asScala decrypted.get(encrypted) shouldBe Some(value) } - "decrypt aes 256 encoded value stored in file with utf-8 encoding" in new TestContext with ConfiguredProvider { + "decrypt aes 256 encoded value stored in file with utf-8 encoding" in new TestContext + with ConfiguredProvider { val encrypted = encrypt(value, key) - val providerData = provider.get(s"utf8_file${EncodingAndId.Separator}id1", Set(encrypted).asJava).data().asScala + val providerData = provider + .get(s"utf8_file${EncodingAndId.Separator}id1", Set(encrypted).asJava) + .data() + .asScala val decryptedPath = providerData(encrypted) decryptedPath should startWith(s"$tmpDir/secrets/") decryptedPath.toLowerCase.contains(encrypted.toLowerCase) shouldBe false - Using(Source.fromFile(decryptedPath))(_.getLines().mkString) shouldBe Success(value) + Using(Source.fromFile(decryptedPath))(_.getLines().mkString) shouldBe Success( + value + ) } - "decrypt aes 256 encoded value stored in file with base64 encoding" in new TestContext with ConfiguredProvider { + "decrypt aes 256 encoded value stored in file with base64 encoding" in new TestContext + with ConfiguredProvider { val bytesAmount = 100 val bytesInput = Array.fill[Byte](bytesAmount)(0) Random.nextBytes(bytesInput) val encrypted = encrypt(Base64.getEncoder.encodeToString(bytesInput), key) - val providerData = provider.get(s"${Encoding.BASE64_FILE}${EncodingAndId.Separator}fileId1", Set(encrypted).asJava).data().asScala + val providerData = provider + .get( + s"${Encoding.BASE64_FILE}${EncodingAndId.Separator}fileId1", + Set(encrypted).asJava + ) + .data() + .asScala val decryptedPath = providerData(encrypted) decryptedPath should startWith(s"$tmpDir/secrets/") @@ -72,7 +90,6 @@ class Aes256DecodingProviderTest bytesConsumed.toList shouldBe bytesInput.toList } - "transform value referencing to the provider" in new TestContext { val value = "hi!" val encrypted = encrypt(value, key) diff --git a/src/test/scala/io/lenses/connect/secrets/providers/AesDecodingTestHelper.scala b/src/test/scala/io/lenses/connect/secrets/providers/AesDecodingTestHelper.scala index ec3137c..6a957d0 100644 --- a/src/test/scala/io/lenses/connect/secrets/providers/AesDecodingTestHelper.scala +++ b/src/test/scala/io/lenses/connect/secrets/providers/AesDecodingTestHelper.scala @@ -9,33 +9,41 @@ import scala.util.Try object AesDecodingTestHelper { private val AES = "AES" - - def encrypt(s: String, key: String): String = { - val iv = InitializationVector() - encryptBytes(s.getBytes("UTF-8"), iv, key).map(encrypted => - base64Encode(iv.bytes) + INITIALISATION_VECTOR_SEPARATOR + base64Encode(encrypted) - ).get - } - private def encryptBytes( - bytes: Array[Byte], - iv: InitializationVector, - key: String - ): Try[Array[Byte]] = - for { - cipher <- getCipher(Cipher.ENCRYPT_MODE, iv, key) - encrypted <- Try(cipher.doFinal(bytes)) - } yield encrypted + def encrypt(s: String, key: String): String = { + val iv = InitializationVector() + encryptBytes(s.getBytes("UTF-8"), iv, key) + .map(encrypted => + base64Encode(iv.bytes) + INITIALISATION_VECTOR_SEPARATOR + base64Encode( + encrypted + ) + ) + .get + } + + private def encryptBytes( + bytes: Array[Byte], + iv: InitializationVector, + key: String + ): Try[Array[Byte]] = + for { + cipher <- getCipher(Cipher.ENCRYPT_MODE, iv, key) + encrypted <- Try(cipher.doFinal(bytes)) + } yield encrypted - private def getCipher(mode: Int, iv: InitializationVector, key: String): Try[Cipher] = - Try { - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - val ivSpec = new IvParameterSpec(iv.bytes) - val secret = new SecretKeySpec(key.getBytes("UTF-8"), AES) - cipher.init(mode, secret, ivSpec) - cipher - } + private def getCipher( + mode: Int, + iv: InitializationVector, + key: String + ): Try[Cipher] = + Try { + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + val ivSpec = new IvParameterSpec(iv.bytes) + val secret = new SecretKeySpec(key.getBytes("UTF-8"), AES) + cipher.init(mode, secret, ivSpec) + cipher + } - private def base64Encode(bytes: Array[Byte]) = - Base64.getEncoder().encodeToString(bytes) + private def base64Encode(bytes: Array[Byte]) = + Base64.getEncoder().encodeToString(bytes) } diff --git a/src/test/scala/io/lenses/connect/secrets/providers/AzureSecretProviderTest.scala b/src/test/scala/io/lenses/connect/secrets/providers/AzureSecretProviderTest.scala index 4afb193..3ae7696 100644 --- a/src/test/scala/io/lenses/connect/secrets/providers/AzureSecretProviderTest.scala +++ b/src/test/scala/io/lenses/connect/secrets/providers/AzureSecretProviderTest.scala @@ -1,414 +1,438 @@ -/* - * - * * Copyright 2017-2020 Lenses.io Ltd - * - */ - -package io.lenses.connect.secrets.providers - -import com.azure.security.keyvault.secrets.SecretClient -import com.azure.security.keyvault.secrets.models.{KeyVaultSecret, SecretProperties} -import io.lenses.connect.secrets.config.{AzureProviderConfig, AzureProviderSettings} -import io.lenses.connect.secrets.connect -import io.lenses.connect.secrets.connect.AuthMode -import org.apache.kafka.common.config.provider.ConfigProvider -import org.apache.kafka.common.config.{ConfigData, ConfigTransformer} -import org.apache.kafka.connect.errors.ConnectException -import org.mockito.MockitoSugar -import org.scalatest.matchers.should.Matchers -import org.scalatest.wordspec.AnyWordSpec - -import java.nio.file.FileSystems -import java.time.OffsetDateTime -import java.util.Base64 -import scala.io.Source -import scala.jdk.CollectionConverters._ -import scala.util.{Success, Using} - -class AzureSecretProviderTest - extends AnyWordSpec - with Matchers - with MockitoSugar { - - val separator: String = FileSystems.getDefault.getSeparator - val tmp: String = - System.getProperty("java.io.tmpdir") + separator + "provider-tests-azure" - - "should get secrets at a path with service principal credentials" in { - val props = Map( - AzureProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, - AzureProviderConfig.AZURE_CLIENT_ID -> "someclientid", - AzureProviderConfig.AZURE_TENANT_ID -> "sometenantid", - AzureProviderConfig.AZURE_SECRET_ID -> "somesecretid" - ).asJava - - val provider = new AzureSecretProvider - provider.configure(props) - - val secretKey = "my-key" - val secretValue = "secret-value" - val secretPath = "my-path.vault.azure.net" - - val client = mock[SecretClient] - val secret = mock[KeyVaultSecret] - val secretProperties = mock[SecretProperties] - val offset = OffsetDateTime.now() - - // string secret - when(secretProperties.getExpiresOn).thenReturn(offset) - when(secret.getValue).thenReturn(secretValue) - when(secret.getProperties).thenReturn(secretProperties) - when(client.getSecret(secretKey)).thenReturn(secret) - - // poke in the mocked client - provider.clientMap += (s"https://$secretPath" -> client) - val data = provider.get(secretPath, Set(secretKey).asJava) - data.data().containsKey(secretKey) - data.data().get(secretKey) shouldBe secretValue - - provider.get("").data().isEmpty shouldBe true - provider.close() - } - - "should get base64 secrets at a path with service principal credentials" in { - val props = Map( - AzureProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, - AzureProviderConfig.AZURE_CLIENT_ID -> "someclientid", - AzureProviderConfig.AZURE_TENANT_ID -> "sometenantid", - AzureProviderConfig.AZURE_SECRET_ID -> "somesecretid" - ).asJava - - val provider = new AzureSecretProvider - provider.configure(props) - - val secretKey = "base64-key" - val secretValue = "base64-secret-value" - val secretPath = "my-path.vault.azure.net" - - val client = mock[SecretClient] - - //base64 secret - val secretb64 = mock[KeyVaultSecret] - val secretPropertiesb64 = mock[SecretProperties] - val ttl = OffsetDateTime.now() - - when(secretPropertiesb64.getExpiresOn).thenReturn(ttl) - when(secretPropertiesb64.getTags) - .thenReturn( - Map(connect.FILE_ENCODING -> connect.Encoding.BASE64.toString).asJava - ) - when(secretb64.getValue).thenReturn( - Base64.getEncoder.encodeToString(secretValue.getBytes) - ) - when(secretb64.getProperties).thenReturn(secretPropertiesb64) - when(client.getSecret(secretKey)).thenReturn(secretb64) - - // poke in the mocked client - provider.clientMap += (s"https://$secretPath" -> client) - val data = provider.get(secretPath, Set(secretKey).asJava) - data.data().get(secretKey) shouldBe secretValue - - provider.get("").data().isEmpty shouldBe true - provider.close() - } - - "should get base64 secrets and write to file" in { - val props = Map( - AzureProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, - AzureProviderConfig.AZURE_CLIENT_ID -> "someclientid", - AzureProviderConfig.AZURE_TENANT_ID -> "sometenantid", - AzureProviderConfig.AZURE_SECRET_ID -> "somesecretid", - connect.FILE_DIR -> tmp - ).asJava - - val provider = new AzureSecretProvider - provider.configure(props) - val secretKey = "base64-key" - val secretValue = "base64-secret-value" - val secretPath = "my-path.vault.azure.net" - - val client = mock[SecretClient] - - //base64 secret - val secretb64 = mock[KeyVaultSecret] - val secretPropertiesb64 = mock[SecretProperties] - val ttl = OffsetDateTime.now() - - when(secretPropertiesb64.getExpiresOn).thenReturn(ttl) - when(secretPropertiesb64.getTags) - .thenReturn( - Map(connect.FILE_ENCODING -> connect.Encoding.BASE64_FILE.toString).asJava - ) - when(secretb64.getValue).thenReturn( - Base64.getEncoder.encodeToString(secretValue.getBytes) - ) - when(secretb64.getProperties).thenReturn(secretPropertiesb64) - when(client.getSecret(secretKey)).thenReturn(secretb64) - when(client.getVaultUrl).thenReturn(s"https://$secretPath") - - // poke in the mocked client - provider.clientMap += (s"https://$secretPath" -> client) - val data = provider.get(secretPath, Set(secretKey).asJava) - val outputFile = data.data().get(secretKey) - - outputFile shouldBe s"$tmp$separator$secretPath$separator$secretKey" - - Using(Source.fromFile(outputFile))(_.getLines().mkString) shouldBe Success(secretValue) - - provider.get("").data().isEmpty shouldBe true - provider.close() - } - - "should get utf secrets and write to file" in { - val props = Map( - AzureProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, - AzureProviderConfig.AZURE_CLIENT_ID -> "someclientid", - AzureProviderConfig.AZURE_TENANT_ID -> "sometenantid", - AzureProviderConfig.AZURE_SECRET_ID -> "somesecretid", - connect.FILE_DIR -> tmp - ).asJava - - val provider = new AzureSecretProvider - provider.configure(props) - - val secretKey = "utf8-key" - val secretValue = "utf8-secret-value" - val secretPath = "my-path.vault.azure.net" - - val client = mock[SecretClient] - val secret = mock[KeyVaultSecret] - val secretProperties = mock[SecretProperties] - val ttl = OffsetDateTime.now() - - when(secretProperties.getExpiresOn).thenReturn(ttl) - when(secretProperties.getTags) - .thenReturn( - Map(connect.FILE_ENCODING -> connect.Encoding.UTF8_FILE.toString).asJava - ) - when(secret.getValue).thenReturn(secretValue) - when(secret.getProperties).thenReturn(secretProperties) - when(client.getSecret(secretKey)).thenReturn(secret) - when(client.getVaultUrl).thenReturn(s"https://$secretPath") - - // poke in the mocked client - provider.clientMap += (s"https://$secretPath" -> client) - val data = provider.get(secretPath, Set(secretKey).asJava) - val outputFile = data.data().get(secretKey) - - outputFile shouldBe s"$tmp$separator$secretPath$separator$secretKey" - - Using(Source.fromFile(outputFile))(_.getLines().mkString) shouldBe Success(secretValue) - - provider.get("").data().isEmpty shouldBe true - provider.close() - } - - "should use cache" in { - val props = Map( - AzureProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, - AzureProviderConfig.AZURE_CLIENT_ID -> "someclientid", - AzureProviderConfig.AZURE_TENANT_ID -> "sometenantid", - AzureProviderConfig.AZURE_SECRET_ID -> "somesecretid", - connect.FILE_DIR -> tmp - ).asJava - - val provider = new AzureSecretProvider - provider.configure(props) - - val secretKey = "utf8-key" - val secretPath = "my-path.vault.azure.net" - - val client = mock[SecretClient] - - // poke in the mocked client - provider.clientMap += (s"https://$secretPath" -> client) - val now = OffsetDateTime.now().plusMinutes(10) - val cachedData = new ConfigData(Map(secretKey -> secretPath).asJava) - val cached = (Some(now), cachedData) - - // add to cache - provider.cache += (s"https://$secretPath" -> cached) - val data = provider.get(secretPath, Set(secretKey).asJava) - data.data().containsKey(secretKey) - } - - "should use not cache because of expiry" in { - val props = Map( - AzureProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, - AzureProviderConfig.AZURE_CLIENT_ID -> "someclientid", - AzureProviderConfig.AZURE_TENANT_ID -> "sometenantid", - AzureProviderConfig.AZURE_SECRET_ID -> "somesecretid", - connect.FILE_DIR -> tmp - ).asJava - - val provider = new AzureSecretProvider - provider.configure(props) - - val secretKey = "utf8-key" - val secretValue = "utf8-secret-value" - val secretPath = "my-path.vault.azure.net" - val vaultUrl = s"https://$secretPath" - - val client = mock[SecretClient] - val secret = mock[KeyVaultSecret] - val secretProperties = mock[SecretProperties] - val ttl = OffsetDateTime.now().plusHours(1) - - when(secretProperties.getExpiresOn).thenReturn(ttl) - when(secretProperties.getTags) - .thenReturn( - Map(connect.FILE_ENCODING -> connect.Encoding.UTF8_FILE.toString).asJava - ) - when(secret.getValue).thenReturn(secretValue) - when(secret.getProperties).thenReturn(secretProperties) - when(client.getSecret(secretKey)).thenReturn(secret) - when(client.getVaultUrl).thenReturn(vaultUrl) - - // poke in the mocked client - provider.clientMap += (vaultUrl -> client) - //put expiry of cache 1 second behind - val now = OffsetDateTime.now().minusSeconds(1) - val cachedData = new ConfigData(Map(secretKey -> secretPath).asJava) - val cached = (Some(now), cachedData) - - // add to cache - provider.cache += (vaultUrl -> cached) - val data = provider.get(secretPath, Set(secretKey).asJava) - data.data().containsKey(secretKey) - // ttl should be in future now in cache - provider.cache(vaultUrl)._1.get shouldBe ttl - } - - "should use not cache because of different keys" in { - val props = Map( - AzureProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, - AzureProviderConfig.AZURE_CLIENT_ID -> "someclientid", - AzureProviderConfig.AZURE_TENANT_ID -> "sometenantid", - AzureProviderConfig.AZURE_SECRET_ID -> "somesecretid", - connect.FILE_DIR -> tmp - ).asJava - - val provider = new AzureSecretProvider - provider.configure(props) - - val secretKey = "utf8-key" - val secretValue = "utf8-secret-value" - val secretPath = "my-path.vault.azure.net" - val vaultUrl = s"https://$secretPath" - - val client = mock[SecretClient] - val secret = mock[KeyVaultSecret] - val secretProperties = mock[SecretProperties] - val ttl = OffsetDateTime.now().plusHours(1) - - when(secretProperties.getExpiresOn).thenReturn(ttl) - when(secretProperties.getTags) - .thenReturn( - Map(connect.FILE_ENCODING -> connect.Encoding.UTF8_FILE.toString).asJava - ) - when(secret.getValue).thenReturn(secretValue) - when(secret.getProperties).thenReturn(secretProperties) - when(client.getSecret(secretKey)).thenReturn(secret) - when(client.getVaultUrl).thenReturn(vaultUrl) - - // poke in the mocked client - provider.clientMap += (vaultUrl -> client) - //put expiry of cache 1 second behind - val now = OffsetDateTime.now() - val cachedData = new ConfigData(Map("old-key" -> secretPath).asJava) - val cached = (Some(now), cachedData) - - // add to cache - provider.cache += (vaultUrl -> cached) - val data = provider.get(secretPath, Set(secretKey).asJava) - data.data().containsKey(secretKey) - } - - "should throw an exception if client id not set and not default auth mode" in { - - intercept[ConnectException] { - AzureProviderSettings( - AzureProviderConfig( - Map(AzureProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString).asJava - ) - ) - } - } - - "should throw an exception if tenant id not set and not default auth mode" in { - - intercept[ConnectException] { - AzureProviderSettings( - AzureProviderConfig( - Map( - AzureProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, - AzureProviderConfig.AZURE_CLIENT_ID -> "someclientid" - ).asJava - ) - ) - } - } - - "should throw an exception if secret id not set and not default auth mode" in { - - intercept[ConnectException] { - AzureProviderSettings( - AzureProviderConfig( - Map( - AzureProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, - AzureProviderConfig.AZURE_CLIENT_ID -> "someclientid", - AzureProviderConfig.AZURE_TENANT_ID -> "sometenantid" - ).asJava - ) - ) - } - } - - "should not throw an exception if service principals not set and default auth mode" in { - - val settings = AzureProviderSettings( - AzureProviderConfig( - Map(AzureProviderConfig.AUTH_METHOD -> AuthMode.DEFAULT.toString).asJava - ) - ) - - settings.authMode shouldBe AuthMode.DEFAULT - } - - "check transformer" in { - val props1 = Map( - AzureProviderConfig.AZURE_CLIENT_ID -> "someclientid", - AzureProviderConfig.AZURE_TENANT_ID -> "sometenantid", - AzureProviderConfig.AZURE_SECRET_ID -> "somesecretid" - ).asJava - - val secretKey = "key-1" - val secretValue = "utf8-secret-value" - val secretPath = "my-path.vault.azure.net" - - val client = mock[SecretClient] - val secret = mock[KeyVaultSecret] - val secretProperties = mock[SecretProperties] - val offset = OffsetDateTime.now() - - // string secret - when(secretProperties.getExpiresOn).thenReturn(offset) - when(secret.getValue).thenReturn(secretValue) - when(secret.getProperties).thenReturn(secretProperties) - when(client.getSecret(secretKey)).thenReturn(secret) - - val provider = new AzureSecretProvider() - provider.configure(props1) - - provider.clientMap += (s"https://$secretPath" -> client) - - // check the workerconfigprovider - val map = new java.util.HashMap[String, ConfigProvider]() - map.put("azure", provider) - val transformer = new ConfigTransformer(map) - val props2 = - Map("mykey" -> "${azure:my-path.vault.azure.net:key-1}").asJava - val data = transformer.transform(props2) - data.data().get("mykey") shouldBe secretValue - } -} +/* + * + * * Copyright 2017-2020 Lenses.io Ltd + * + */ + +package io.lenses.connect.secrets.providers + +import com.azure.security.keyvault.secrets.SecretClient +import com.azure.security.keyvault.secrets.models.{ + KeyVaultSecret, + SecretProperties +} +import io.lenses.connect.secrets.TmpDirUtil.getTempDir +import io.lenses.connect.secrets.config.{ + AzureProviderConfig, + AzureProviderSettings +} +import io.lenses.connect.secrets.connect +import io.lenses.connect.secrets.connect.AuthMode +import org.apache.kafka.common.config.provider.ConfigProvider +import org.apache.kafka.common.config.{ConfigData, ConfigTransformer} +import org.apache.kafka.connect.errors.ConnectException +import org.mockito.Mockito.when +import org.scalatestplus.mockito.MockitoSugar +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +import java.nio.file.FileSystems +import java.time.OffsetDateTime +import java.util.Base64 +import scala.io.Source +import scala.jdk.CollectionConverters._ +import scala.util.{Success, Using} + +class AzureSecretProviderTest + extends AnyWordSpec + with Matchers + with MockitoSugar { + + val separator: String = FileSystems.getDefault.getSeparator + val tmp: String = + s"$getTempDir${separator}provider-tests-azure" + + "should get secrets at a path with service principal credentials" in { + val props = Map( + AzureProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, + AzureProviderConfig.AZURE_CLIENT_ID -> "someclientid", + AzureProviderConfig.AZURE_TENANT_ID -> "sometenantid", + AzureProviderConfig.AZURE_SECRET_ID -> "somesecretid" + ).asJava + + val provider = new AzureSecretProvider + provider.configure(props) + + val secretKey = "my-key" + val secretValue = "secret-value" + val secretPath = "my-path.vault.azure.net" + + val client = mock[SecretClient] + when(client.getVaultUrl).thenReturn(s"https://$secretPath") + val secret = mock[KeyVaultSecret] + val secretProperties = mock[SecretProperties] + val offset = OffsetDateTime.now() + + // string secret + when(secretProperties.getExpiresOn).thenReturn(offset) + when(secret.getValue).thenReturn(secretValue) + when(secret.getProperties).thenReturn(secretProperties) + when(client.getSecret(secretKey)).thenReturn(secret) + + // poke in the mocked client + provider.clientMap += (s"https://$secretPath" -> client) + val data = provider.get(secretPath, Set(secretKey).asJava) + data.data().containsKey(secretKey) + data.data().get(secretKey) shouldBe secretValue + + provider.get("").data().isEmpty shouldBe true + provider.close() + } + + "should get base64 secrets at a path with service principal credentials" in { + val props = Map( + AzureProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, + AzureProviderConfig.AZURE_CLIENT_ID -> "someclientid", + AzureProviderConfig.AZURE_TENANT_ID -> "sometenantid", + AzureProviderConfig.AZURE_SECRET_ID -> "somesecretid" + ).asJava + + val provider = new AzureSecretProvider + provider.configure(props) + + val secretKey = "base64-key" + val secretValue = "base64-secret-value" + val secretPath = "my-path.vault.azure.net" + + val client = mock[SecretClient] + when(client.getVaultUrl).thenReturn(s"https://$secretPath") + + //base64 secret + val secretb64 = mock[KeyVaultSecret] + val secretPropertiesb64 = mock[SecretProperties] + val ttl = OffsetDateTime.now() + + when(secretPropertiesb64.getExpiresOn).thenReturn(ttl) + when(secretPropertiesb64.getTags) + .thenReturn( + Map(connect.FILE_ENCODING -> connect.Encoding.BASE64.toString).asJava + ) + when(secretb64.getValue).thenReturn( + Base64.getEncoder.encodeToString(secretValue.getBytes) + ) + when(secretb64.getProperties).thenReturn(secretPropertiesb64) + when(client.getSecret(secretKey)).thenReturn(secretb64) + + // poke in the mocked client + provider.clientMap += (s"https://$secretPath" -> client) + val data = provider.get(secretPath, Set(secretKey).asJava) + data.data().get(secretKey) shouldBe secretValue + + provider.get("").data().isEmpty shouldBe true + provider.close() + } + + "should get base64 secrets and write to file" in { + val props = Map( + AzureProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, + AzureProviderConfig.AZURE_CLIENT_ID -> "someclientid", + AzureProviderConfig.AZURE_TENANT_ID -> "sometenantid", + AzureProviderConfig.AZURE_SECRET_ID -> "somesecretid", + connect.FILE_DIR -> tmp + ).asJava + + val provider = new AzureSecretProvider + provider.configure(props) + val secretKey = "base64-key" + val secretValue = "base64-secret-value" + val secretPath = "my-path.vault.azure.net" + + val client = mock[SecretClient] + when(client.getVaultUrl).thenReturn(s"https://$secretPath") + + //base64 secret + val secretb64 = mock[KeyVaultSecret] + val secretPropertiesb64 = mock[SecretProperties] + val ttl = OffsetDateTime.now() + + when(secretPropertiesb64.getExpiresOn).thenReturn(ttl) + when(secretPropertiesb64.getTags) + .thenReturn( + Map(connect.FILE_ENCODING -> connect.Encoding.BASE64_FILE.toString).asJava + ) + when(secretb64.getValue).thenReturn( + Base64.getEncoder.encodeToString(secretValue.getBytes) + ) + when(secretb64.getProperties).thenReturn(secretPropertiesb64) + when(client.getSecret(secretKey)).thenReturn(secretb64) + when(client.getVaultUrl).thenReturn(s"https://$secretPath") + + // poke in the mocked client + provider.clientMap += (s"https://$secretPath" -> client) + val data = provider.get(secretPath, Set(secretKey).asJava) + val outputFile = data.data().get(secretKey) + + outputFile shouldBe s"$tmp$separator$secretPath$separator$secretKey" + + Using(Source.fromFile(outputFile))(_.getLines().mkString) shouldBe Success( + secretValue + ) + + provider.get("").data().isEmpty shouldBe true + provider.close() + } + + "should get utf secrets and write to file" in { + val props = Map( + AzureProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, + AzureProviderConfig.AZURE_CLIENT_ID -> "someclientid", + AzureProviderConfig.AZURE_TENANT_ID -> "sometenantid", + AzureProviderConfig.AZURE_SECRET_ID -> "somesecretid", + connect.FILE_DIR -> tmp + ).asJava + + val provider = new AzureSecretProvider + provider.configure(props) + + val secretKey = "utf8-key" + val secretValue = "utf8-secret-value" + val secretPath = "my-path.vault.azure.net" + + val client = mock[SecretClient] + when(client.getVaultUrl).thenReturn(s"https://$secretPath") + + val secret = mock[KeyVaultSecret] + val secretProperties = mock[SecretProperties] + val ttl = OffsetDateTime.now() + + when(secretProperties.getExpiresOn).thenReturn(ttl) + when(secretProperties.getTags) + .thenReturn( + Map(connect.FILE_ENCODING -> connect.Encoding.UTF8_FILE.toString).asJava + ) + when(secret.getValue).thenReturn(secretValue) + when(secret.getProperties).thenReturn(secretProperties) + when(client.getSecret(secretKey)).thenReturn(secret) + when(client.getVaultUrl).thenReturn(s"https://$secretPath") + + // poke in the mocked client + provider.clientMap += (s"https://$secretPath" -> client) + val data = provider.get(secretPath, Set(secretKey).asJava) + val outputFile = data.data().get(secretKey) + + outputFile shouldBe s"$tmp$separator$secretPath$separator$secretKey" + + Using(Source.fromFile(outputFile))(_.getLines().mkString) shouldBe Success( + secretValue + ) + + provider.get("").data().isEmpty shouldBe true + provider.close() + } + + "should use cache" in { + val props = Map( + AzureProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, + AzureProviderConfig.AZURE_CLIENT_ID -> "someclientid", + AzureProviderConfig.AZURE_TENANT_ID -> "sometenantid", + AzureProviderConfig.AZURE_SECRET_ID -> "somesecretid", + connect.FILE_DIR -> tmp + ).asJava + + val provider = new AzureSecretProvider + provider.configure(props) + + val secretKey = "utf8-key" + val secretPath = "my-path.vault.azure.net" + + val client = mock[SecretClient] + when(client.getVaultUrl).thenReturn(s"https://$secretPath") + + // poke in the mocked client + provider.clientMap += (s"https://$secretPath" -> client) + val now = OffsetDateTime.now().plusMinutes(10) + val cachedData = new ConfigData(Map(secretKey -> secretPath).asJava) + val cached = (Some(now), cachedData) + + // add to cache + provider.cache += (s"https://$secretPath" -> cached) + val data = provider.get(secretPath, Set(secretKey).asJava) + data.data().containsKey(secretKey) + } + + "should use not cache because of expiry" in { + val props = Map( + AzureProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, + AzureProviderConfig.AZURE_CLIENT_ID -> "someclientid", + AzureProviderConfig.AZURE_TENANT_ID -> "sometenantid", + AzureProviderConfig.AZURE_SECRET_ID -> "somesecretid", + connect.FILE_DIR -> tmp + ).asJava + + val provider = new AzureSecretProvider + provider.configure(props) + + val secretKey = "utf8-key" + val secretValue = "utf8-secret-value" + val secretPath = "my-path.vault.azure.net" + val vaultUrl = s"https://$secretPath" + + val client = mock[SecretClient] + when(client.getVaultUrl).thenReturn(s"https://$secretPath") + + val secret = mock[KeyVaultSecret] + val secretProperties = mock[SecretProperties] + val ttl = OffsetDateTime.now().plusHours(1) + + when(secretProperties.getExpiresOn).thenReturn(ttl) + when(secretProperties.getTags) + .thenReturn( + Map(connect.FILE_ENCODING -> connect.Encoding.UTF8_FILE.toString).asJava + ) + when(secret.getValue).thenReturn(secretValue) + when(secret.getProperties).thenReturn(secretProperties) + when(client.getSecret(secretKey)).thenReturn(secret) + when(client.getVaultUrl).thenReturn(vaultUrl) + + // poke in the mocked client + provider.clientMap += (vaultUrl -> client) + //put expiry of cache 1 second behind + val now = OffsetDateTime.now().minusSeconds(1) + val cachedData = new ConfigData(Map(secretKey -> secretPath).asJava) + val cached = (Some(now), cachedData) + + // add to cache + provider.cache += (vaultUrl -> cached) + val data = provider.get(secretPath, Set(secretKey).asJava) + data.data().containsKey(secretKey) + // ttl should be in future now in cache + provider.cache(vaultUrl)._1.get shouldBe ttl + } + + "should use not cache because of different keys" in { + val props = Map( + AzureProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, + AzureProviderConfig.AZURE_CLIENT_ID -> "someclientid", + AzureProviderConfig.AZURE_TENANT_ID -> "sometenantid", + AzureProviderConfig.AZURE_SECRET_ID -> "somesecretid", + connect.FILE_DIR -> tmp + ).asJava + + val provider = new AzureSecretProvider + provider.configure(props) + + val secretKey = "utf8-key" + val secretValue = "utf8-secret-value" + val secretPath = "my-path.vault.azure.net" + val vaultUrl = s"https://$secretPath" + + val client = mock[SecretClient] + when(client.getVaultUrl).thenReturn(s"https://$secretPath") + + val secret = mock[KeyVaultSecret] + val secretProperties = mock[SecretProperties] + val ttl = OffsetDateTime.now().plusHours(1) + + when(secretProperties.getExpiresOn).thenReturn(ttl) + when(secretProperties.getTags) + .thenReturn( + Map(connect.FILE_ENCODING -> connect.Encoding.UTF8_FILE.toString).asJava + ) + when(secret.getValue).thenReturn(secretValue) + when(secret.getProperties).thenReturn(secretProperties) + when(client.getSecret(secretKey)).thenReturn(secret) + when(client.getVaultUrl).thenReturn(vaultUrl) + + // poke in the mocked client + provider.clientMap += (vaultUrl -> client) + //put expiry of cache 1 second behind + val now = OffsetDateTime.now() + val cachedData = new ConfigData(Map("old-key" -> secretPath).asJava) + val cached = (Some(now), cachedData) + + // add to cache + provider.cache += (vaultUrl -> cached) + val data = provider.get(secretPath, Set(secretKey).asJava) + data.data().containsKey(secretKey) + } + + "should throw an exception if client id not set and not default auth mode" in { + + intercept[ConnectException] { + AzureProviderSettings( + AzureProviderConfig( + Map(AzureProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString).asJava + ) + ) + } + } + + "should throw an exception if tenant id not set and not default auth mode" in { + + intercept[ConnectException] { + AzureProviderSettings( + AzureProviderConfig( + Map( + AzureProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, + AzureProviderConfig.AZURE_CLIENT_ID -> "someclientid" + ).asJava + ) + ) + } + } + + "should throw an exception if secret id not set and not default auth mode" in { + + intercept[ConnectException] { + AzureProviderSettings( + AzureProviderConfig( + Map( + AzureProviderConfig.AUTH_METHOD -> AuthMode.CREDENTIALS.toString, + AzureProviderConfig.AZURE_CLIENT_ID -> "someclientid", + AzureProviderConfig.AZURE_TENANT_ID -> "sometenantid" + ).asJava + ) + ) + } + } + + "should not throw an exception if service principals not set and default auth mode" in { + + val settings = AzureProviderSettings( + AzureProviderConfig( + Map(AzureProviderConfig.AUTH_METHOD -> AuthMode.DEFAULT.toString).asJava + ) + ) + + settings.authMode shouldBe AuthMode.DEFAULT + } + + "check transformer" in { + val props1 = Map( + AzureProviderConfig.AZURE_CLIENT_ID -> "someclientid", + AzureProviderConfig.AZURE_TENANT_ID -> "sometenantid", + AzureProviderConfig.AZURE_SECRET_ID -> "somesecretid" + ).asJava + + val secretKey = "key-1" + val secretValue = "utf8-secret-value" + val secretPath = "my-path.vault.azure.net" + + val client = mock[SecretClient] + when(client.getVaultUrl).thenReturn(s"https://$secretPath") + + val secret = mock[KeyVaultSecret] + val secretProperties = mock[SecretProperties] + val offset = OffsetDateTime.now() + + // string secret + when(secretProperties.getExpiresOn).thenReturn(offset) + when(secret.getValue).thenReturn(secretValue) + when(secret.getProperties).thenReturn(secretProperties) + when(client.getSecret(secretKey)).thenReturn(secret) + + val provider = new AzureSecretProvider() + provider.configure(props1) + + provider.clientMap += (s"https://$secretPath" -> client) + + // check the workerconfigprovider + val map = new java.util.HashMap[String, ConfigProvider]() + map.put("azure", provider) + val transformer = new ConfigTransformer(map) + val props2 = + Map("mykey" -> "${azure:my-path.vault.azure.net:key-1}").asJava + val data = transformer.transform(props2) + data.data().get("mykey") shouldBe secretValue + } +} diff --git a/src/test/scala/io/lenses/connect/secrets/providers/DecodeTest.scala b/src/test/scala/io/lenses/connect/secrets/providers/DecodeTest.scala index eff5307..95794b7 100644 --- a/src/test/scala/io/lenses/connect/secrets/providers/DecodeTest.scala +++ b/src/test/scala/io/lenses/connect/secrets/providers/DecodeTest.scala @@ -1,109 +1,109 @@ -/* - * - * * Copyright 2017-2020 Lenses.io Ltd - * - */ - -package io.lenses.connect.secrets.providers - -import io.lenses.connect.secrets.connect -import io.lenses.connect.secrets.connect.Encoding -import org.apache.commons.io.FileUtils -import org.scalatest.matchers.should.Matchers -import org.scalatest.wordspec.AnyWordSpec - -import java.io.File -import java.nio.file.FileSystems -import java.time.OffsetDateTime -import java.util.Base64 - -class DecodeTest extends AnyWordSpec with Matchers { - - val separator: String = FileSystems.getDefault.getSeparator - val tmp: String = - System.getProperty("java.io.tmpdir") + separator + "decoder-tests" - - def cleanUp(fileName: String): AnyVal = { - val tmpFile = new File(fileName) - if (tmpFile.exists) tmpFile.delete() - } - - "should decode UTF" in { - connect.decodeKey(None, "my-key", "secret", { _ => - fail("No files here") - }) shouldBe "secret" - connect.decodeKey(Some(Encoding.UTF8), "my-key", "secret", { _ => - fail("No files here") - }) shouldBe "secret" - } - - "should decode BASE64" in { - val value = Base64.getEncoder.encodeToString("secret".getBytes) - connect.decodeKey(Some(Encoding.BASE64), s"my-key", value, { _ => - fail("No files here") - }) shouldBe "secret" - } - - "should decode BASE64 and write to a file" in { - val fileName = s"${tmp}my-file-base64" - - val value = Base64.getEncoder.encodeToString("secret".getBytes) - var written = false - connect.decodeKey( - Some(Encoding.BASE64_FILE), - s"my-key", - value, { _ => - written = true - fileName - } - ) shouldBe fileName - written shouldBe true - } - - "should decode and write a jks" in { - val fileName = s"${tmp}my-file-base64-jks" - val jksFile: String = - getClass.getClassLoader.getResource("keystore.jks").getPath - val fileContent = FileUtils.readFileToByteArray(new File(jksFile)) - val jksEncoded = Base64.getEncoder.encodeToString(fileContent) - - var written = false - connect.decodeKey( - Some(Encoding.BASE64_FILE), - s"keystore.jks", - jksEncoded, { _ => - written = true - fileName - } - ) shouldBe fileName - - written shouldBe true - } - - "should decode UTF8 and write to a file" in { - val fileName = s"${tmp}my-file-utf8" - var written = false - - connect.decodeKey( - Some(Encoding.UTF8_FILE), - s"my-key", - "secret", { _ => - written = true - fileName - } - ) shouldBe fileName - written shouldBe true - } - - "min list test" in { - val now = OffsetDateTime.now() - val secrets = Map( - "ke3" -> ("value", Some(OffsetDateTime.now().plusHours(3))), - "key1" -> ("value", Some(now)), - "key2" -> ("value", Some(OffsetDateTime.now().plusHours(1))) - ) - - val (expiry, _) = connect.getSecretsAndExpiry(secrets) - expiry shouldBe Some(now) - } -} +/* + * + * * Copyright 2017-2020 Lenses.io Ltd + * + */ + +package io.lenses.connect.secrets.providers + +import io.lenses.connect.secrets.connect +import io.lenses.connect.secrets.connect.Encoding +import org.apache.commons.io.FileUtils +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +import java.io.File +import java.nio.file.FileSystems +import java.time.OffsetDateTime +import java.util.Base64 + +class DecodeTest extends AnyWordSpec with Matchers { + + val separator: String = FileSystems.getDefault.getSeparator + val tmp: String = + System.getProperty("java.io.tmpdir") + separator + "decoder-tests" + + def cleanUp(fileName: String): AnyVal = { + val tmpFile = new File(fileName) + if (tmpFile.exists) tmpFile.delete() + } + + "should decode UTF" in { + connect.decodeKey(None, "my-key", "secret", { _ => + fail("No files here") + }) shouldBe "secret" + connect.decodeKey(Some(Encoding.UTF8), "my-key", "secret", { _ => + fail("No files here") + }) shouldBe "secret" + } + + "should decode BASE64" in { + val value = Base64.getEncoder.encodeToString("secret".getBytes) + connect.decodeKey(Some(Encoding.BASE64), s"my-key", value, { _ => + fail("No files here") + }) shouldBe "secret" + } + + "should decode BASE64 and write to a file" in { + val fileName = s"${tmp}my-file-base64" + + val value = Base64.getEncoder.encodeToString("secret".getBytes) + var written = false + connect.decodeKey( + Some(Encoding.BASE64_FILE), + s"my-key", + value, { _ => + written = true + fileName + } + ) shouldBe fileName + written shouldBe true + } + + "should decode and write a jks" in { + val fileName = s"${tmp}my-file-base64-jks" + val jksFile: String = + getClass.getClassLoader.getResource("keystore.jks").getPath + val fileContent = FileUtils.readFileToByteArray(new File(jksFile)) + val jksEncoded = Base64.getEncoder.encodeToString(fileContent) + + var written = false + connect.decodeKey( + Some(Encoding.BASE64_FILE), + s"keystore.jks", + jksEncoded, { _ => + written = true + fileName + } + ) shouldBe fileName + + written shouldBe true + } + + "should decode UTF8 and write to a file" in { + val fileName = s"${tmp}my-file-utf8" + var written = false + + connect.decodeKey( + Some(Encoding.UTF8_FILE), + s"my-key", + "secret", { _ => + written = true + fileName + } + ) shouldBe fileName + written shouldBe true + } + + "min list test" in { + val now = OffsetDateTime.now() + val secrets = Map( + "ke3" -> ("value", Some(OffsetDateTime.now().plusHours(3))), + "key1" -> ("value", Some(now)), + "key2" -> ("value", Some(OffsetDateTime.now().plusHours(1))) + ) + + val (expiry, _) = connect.getSecretsAndExpiry(secrets) + expiry shouldBe Some(now) + } +} diff --git a/src/test/scala/io/lenses/connect/secrets/providers/ENVSecretProviderTest.scala b/src/test/scala/io/lenses/connect/secrets/providers/ENVSecretProviderTest.scala index 07a4f5f..9a7dbab 100644 --- a/src/test/scala/io/lenses/connect/secrets/providers/ENVSecretProviderTest.scala +++ b/src/test/scala/io/lenses/connect/secrets/providers/ENVSecretProviderTest.scala @@ -1,77 +1,84 @@ -/* - * - * * Copyright 2017-2020 Lenses.io Ltd - * - */ - -package io.lenses.connect.secrets.providers - -import org.apache.kafka.common.config.ConfigTransformer -import org.apache.kafka.common.config.provider.ConfigProvider -import org.scalatest.matchers.should.Matchers -import org.scalatest.wordspec.AnyWordSpec - -import java.nio.file.FileSystems -import java.util.Base64 -import scala.io.Source -import scala.jdk.CollectionConverters._ -import scala.util.{Success, Using} - -class ENVSecretProviderTest extends AnyWordSpec with Matchers { - - val separator: String = FileSystems.getDefault.getSeparator - val tmp: String = - System.getProperty("java.io.tmpdir").stripSuffix(separator) + separator + "provider-tests-env" - - "should filter and match" in { - val provider = new ENVSecretProvider() - provider.vars = Map( - "RANDOM" -> "somevalue", - "CONNECT_CASSANDRA_PASSWORD" -> "secret", - "BASE64" -> s"ENV-base64:${Base64.getEncoder.encodeToString("my-base64-secret".getBytes)}", - "BASE64_FILE" -> s"ENV-mounted-base64:${Base64.getEncoder.encodeToString("my-base64-secret".getBytes)}", - "UTF8_FILE" -> s"ENV-mounted:my-secret" - ) - provider.fileDir = tmp - - val data = provider.get("", Set("CONNECT_CASSANDRA_PASSWORD").asJava) - data.data().get("CONNECT_CASSANDRA_PASSWORD") shouldBe "secret" - data.data().containsKey("RANDOM") shouldBe false - - val data2 = provider.get("", Set("CONNECT_CASSANDRA_PASSWORD", "RANDOM").asJava) - data2.data().get("CONNECT_CASSANDRA_PASSWORD") shouldBe "secret" - data2.data().containsKey("RANDOM") shouldBe true - - val data3 = provider.get("", Set("BASE64").asJava) - data3.data().get("BASE64") shouldBe "my-base64-secret" - - val data4 = provider.get("", Set("BASE64_FILE").asJava) - val outputFile = data4.data().get("BASE64_FILE") - outputFile shouldBe s"$tmp${separator}base64_file" - - Using(Source.fromFile(outputFile))(_.getLines().mkString) shouldBe Success("my-base64-secret") - - val data5 = provider.get("", Set("UTF8_FILE").asJava) - val outputFile5 = data5.data().get("UTF8_FILE") - outputFile5 shouldBe s"$tmp${separator}utf8_file" - - Using(Source.fromFile(outputFile5))(_.getLines().mkString) shouldBe Success("my-secret") - - } - - "check transformer" in { - - val provider = new ENVSecretProvider() - provider.vars = Map("CONNECT_PASSWORD" -> "secret") - - // check the workerconfigprovider - val map = new java.util.HashMap[String, ConfigProvider]() - map.put("env", provider) - val transformer = new ConfigTransformer(map) - val props2 = - Map("mykey" -> "${env::CONNECT_PASSWORD}").asJava - val data = transformer.transform(props2) - data.data().containsKey("value") - data.data().get("mykey") shouldBe "secret" - } -} +/* + * + * * Copyright 2017-2020 Lenses.io Ltd + * + */ + +package io.lenses.connect.secrets.providers + +import org.apache.kafka.common.config.ConfigTransformer +import org.apache.kafka.common.config.provider.ConfigProvider +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +import java.nio.file.FileSystems +import java.util.Base64 +import scala.io.Source +import scala.jdk.CollectionConverters._ +import scala.util.{Success, Using} + +class ENVSecretProviderTest extends AnyWordSpec with Matchers { + + val separator: String = FileSystems.getDefault.getSeparator + val tmp: String = + System + .getProperty("java.io.tmpdir") + .stripSuffix(separator) + separator + "provider-tests-env" + + "should filter and match" in { + val provider = new ENVSecretProvider() + provider.vars = Map( + "RANDOM" -> "somevalue", + "CONNECT_CASSANDRA_PASSWORD" -> "secret", + "BASE64" -> s"ENV-base64:${Base64.getEncoder.encodeToString("my-base64-secret".getBytes)}", + "BASE64_FILE" -> s"ENV-mounted-base64:${Base64.getEncoder.encodeToString("my-base64-secret".getBytes)}", + "UTF8_FILE" -> s"ENV-mounted:my-secret" + ) + provider.fileDir = tmp + + val data = provider.get("", Set("CONNECT_CASSANDRA_PASSWORD").asJava) + data.data().get("CONNECT_CASSANDRA_PASSWORD") shouldBe "secret" + data.data().containsKey("RANDOM") shouldBe false + + val data2 = + provider.get("", Set("CONNECT_CASSANDRA_PASSWORD", "RANDOM").asJava) + data2.data().get("CONNECT_CASSANDRA_PASSWORD") shouldBe "secret" + data2.data().containsKey("RANDOM") shouldBe true + + val data3 = provider.get("", Set("BASE64").asJava) + data3.data().get("BASE64") shouldBe "my-base64-secret" + + val data4 = provider.get("", Set("BASE64_FILE").asJava) + val outputFile = data4.data().get("BASE64_FILE") + outputFile shouldBe s"$tmp${separator}base64_file" + + Using(Source.fromFile(outputFile))(_.getLines().mkString) shouldBe Success( + "my-base64-secret" + ) + + val data5 = provider.get("", Set("UTF8_FILE").asJava) + val outputFile5 = data5.data().get("UTF8_FILE") + outputFile5 shouldBe s"$tmp${separator}utf8_file" + + Using(Source.fromFile(outputFile5))(_.getLines().mkString) shouldBe Success( + "my-secret" + ) + + } + + "check transformer" in { + + val provider = new ENVSecretProvider() + provider.vars = Map("CONNECT_PASSWORD" -> "secret") + + // check the workerconfigprovider + val map = new java.util.HashMap[String, ConfigProvider]() + map.put("env", provider) + val transformer = new ConfigTransformer(map) + val props2 = + Map("mykey" -> "${env::CONNECT_PASSWORD}").asJava + val data = transformer.transform(props2) + data.data().containsKey("value") + data.data().get("mykey") shouldBe "secret" + } +} diff --git a/src/test/scala/io/lenses/connect/secrets/providers/VaultSecretProviderTest.scala b/src/test/scala/io/lenses/connect/secrets/providers/VaultSecretProviderTest.scala index 9522528..a1d82c7 100644 --- a/src/test/scala/io/lenses/connect/secrets/providers/VaultSecretProviderTest.scala +++ b/src/test/scala/io/lenses/connect/secrets/providers/VaultSecretProviderTest.scala @@ -7,7 +7,12 @@ package io.lenses.connect.secrets.providers import com.bettercloud.vault.json.{JsonArray, JsonObject} -import io.lenses.connect.secrets.config.{VaultAuthMethod, VaultProviderConfig, VaultSettings} +import io.lenses.connect.secrets.TmpDirUtil.{getTempDir, separator} +import io.lenses.connect.secrets.config.{ + VaultAuthMethod, + VaultProviderConfig, + VaultSettings +} import io.lenses.connect.secrets.connect import io.lenses.connect.secrets.vault.{MockVault, VaultTestUtils} import org.apache.kafka.common.config.provider.ConfigProvider @@ -18,19 +23,17 @@ import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpec import java.io.File -import java.nio.file.FileSystems import java.util.Base64 import scala.io.Source import scala.jdk.CollectionConverters._ import scala.util.{Success, Using} class VaultSecretProviderTest - extends AnyWordSpec + extends AnyWordSpec with Matchers with BeforeAndAfterAll { - val separator: String = FileSystems.getDefault.getSeparator - val tmp: String = - System.getProperty("java.io.tmpdir") + separator + "provider-tests-vault" + + val tmp: String = s"$getTempDir${separator}provider-tests-vault" val data: JsonObject = new JsonObject().add( "data", @@ -52,7 +55,9 @@ class VaultSecretProviderTest .add("lease_duration", 0) .add("policies", new JsonArray()) - val root: JsonObject = new JsonObject().add("data", data).add("auth", auth) + val root: JsonObject = new JsonObject() + .add("data", data) + .add("auth", auth) .add("renewable", true) val mockVault = new MockVault(200, root.toString) val server: Server = VaultTestUtils.initHttpsMockVault(mockVault) @@ -328,7 +333,9 @@ class VaultSecretProviderTest .get(secretKey) outputFile shouldBe s"$tmp$separator$secretPath$separator$secretKey" - Using(Source.fromFile(outputFile))(_.getLines().mkString) shouldBe Success(secretValue) + Using(Source.fromFile(outputFile))(_.getLines().mkString) shouldBe Success( + secretValue + ) provider.close() } @@ -356,7 +363,9 @@ class VaultSecretProviderTest .get(secretKey) outputFile shouldBe s"$tmp$separator$secretPath$separator$secretKey" - Using(Source.fromFile(outputFile))(_.getLines().mkString) shouldBe Success(secretValue) + Using(Source.fromFile(outputFile))(_.getLines().mkString) shouldBe Success( + secretValue + ) provider.close() }