From 2752bf83963320fd912fb86e93450f3cd5fea119 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Wed, 6 Nov 2019 13:06:17 -0800 Subject: [PATCH 01/53] Added tests for reading using kvv2 --- test/vault/kvv2_test.clj | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 test/vault/kvv2_test.clj diff --git a/test/vault/kvv2_test.clj b/test/vault/kvv2_test.clj new file mode 100644 index 0000000..99fe3a8 --- /dev/null +++ b/test/vault/kvv2_test.clj @@ -0,0 +1,38 @@ +(ns vault.kvv2-test + (:require + [clojure.test :refer [testing deftest is]]) + (:import + (clojure.lang + ExceptionInfo))) + + +(deftest read + (let [lookup-response-valid-path {:data {:data {:foo "bar"} + :metadata {:created_time "2018-03-22T02:24:06.945319214Z" + :deletion_time "" + :destroyed false + :version 1}}} + path-passed-in "path/passed/in" + token-passed-in "fake-token" + vault-url "https://vault.example.amperity.com" + client (vault.core/new-client vault-url)] + (testing "Read responds correctly if secret is successfully located" + (with-redefs + [clj-http.client/get + (fn [url opts] + (is (= (str vault-url "/v1/data/" path-passed-in) url)) + (is (= token-passed-in (get (:headers opts) "X-Vault-Token"))) + lookup-response-valid-path)] + (is (= {:foo "bar"} (vault-kv/read client vault-url))))) + (testing "Read responds correctly if no secret is found" + (with-redefs + [clj-http.client/get + (fn [url opts] + (is (= (str vault-url "/v1/data/" path-passed-in) url)) + (is (= token-passed-in (get (:headers opts) "X-Vault-Token"))) + {:errors []})] + (try + (vault-kv/read client vault-url) + (is false) + (catch ExceptionInfo e + (is (= {:status 404} (ex-data e))))))))) From ca921e0432b846e9e527263a043e94438bab0d8a Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Wed, 6 Nov 2019 15:06:51 -0800 Subject: [PATCH 02/53] Added tests for kvv2 config read/write --- test/vault/kvv2_test.clj | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/test/vault/kvv2_test.clj b/test/vault/kvv2_test.clj index 99fe3a8..867e66d 100644 --- a/test/vault/kvv2_test.clj +++ b/test/vault/kvv2_test.clj @@ -6,7 +6,43 @@ ExceptionInfo))) -(deftest read +(deftest write-config-test + (let [mount "mount" + token-passed-in "fake-token" + vault-url "https://vault.example.amperity.com" + client (vault.core/new-client vault-url) + new-config {:max_versions 5 + :cas_require false + :delete_version_after}] + (testing "Config can be updated with valid call" + (with-redefs + [clj-http.client/post + (fn [url opts] + (is (= (str vault-url "/v1/" mount "/config") url)) + (is (= token-passed-in (get (:headers opts) "X-Vault-Token"))) + (is (= new-config (:data opts))))] + (vault-kv/write-config client mount new-config))))) + + +(deftest read-config-test + (let [config {:max_versions 5 + :cas_require false + :delete_version_after} + mount "mount" + token-passed-in "fake-token" + vault-url "https://vault.example.amperity.com" + client (vault.core/new-client vault-url)] + (testing "Config can be read with valid call" + (with-redefs + [clj-http.client/get + (fn [url opts] + (is (= (str vault-url "/v1/" mount "/config") url)) + (is (= token-passed-in (get (:headers opts) "X-Vault-Token"))) + {:data config})] + (is (= config (vault-kv/read-config client mount))))))) + + +(deftest read-test (let [lookup-response-valid-path {:data {:data {:foo "bar"} :metadata {:created_time "2018-03-22T02:24:06.945319214Z" :deletion_time "" From d9c8cf7d84c3a9e223d7411ffa425e21d89ed9bd Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Wed, 6 Nov 2019 16:01:00 -0800 Subject: [PATCH 03/53] Added secret write tests for kvv2 --- test/vault/kvv2_test.clj | 44 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/test/vault/kvv2_test.clj b/test/vault/kvv2_test.clj index 867e66d..bb6c65d 100644 --- a/test/vault/kvv2_test.clj +++ b/test/vault/kvv2_test.clj @@ -17,11 +17,11 @@ (testing "Config can be updated with valid call" (with-redefs [clj-http.client/post - (fn [url opts] + (fn [url req] (is (= (str vault-url "/v1/" mount "/config") url)) - (is (= token-passed-in (get (:headers opts) "X-Vault-Token"))) - (is (= new-config (:data opts))))] - (vault-kv/write-config client mount new-config))))) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) + (is (= new-config (:data req))))] + (is (true? (vault-kv/write-config client mount new-config))))))) (deftest read-config-test @@ -72,3 +72,39 @@ (is false) (catch ExceptionInfo e (is (= {:status 404} (ex-data e))))))))) + + +(deftest write-test + (let [create-success {:data {:created_time "2018-03-22T02:24:06.945319214Z" + :deletion_time "" + :destroyed false + :version 1}} + write-data {:foo "bar" + :zip "zap"} + options {:cas 0} + path-passed-in "path/passed/in" + token-passed-in "fake-token" + vault-url "https://vault.example.amperity.com" + client (vault.core/new-client vault-url)] + (testing "Write writes and returns true upon success" + (with-redefs + [clj-http.client/post + (fn [url req] + (is (= (str vault-url "/v1/data/" path-passed-in) url)) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) + (is (= {:data write-data + :options options} + (:data req))) + create-success)] + (is (true? (vault-kv/write client vault-url write-data options))))) + (testing "Write returns false upon failure" + (with-redefs + [clj-http.client/post + (fn [url req] + (is (= (str vault-url "/v1/data/" path-passed-in) url)) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) + (is (= {:data write-data + :options options} + (:data req))) + {:errors []})] + (is (false? (vault-kv/write client vault-url write-data options))))))) From be6d629529685a07f16b0d615e79c886c7604d6a Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Wed, 6 Nov 2019 18:06:55 -0800 Subject: [PATCH 04/53] Added tests for vault-logical, need to fix tests for kvv2 --- test/vault/kvv2_test.clj | 21 +++++--- test/vault/logical_test.clj | 102 ++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 test/vault/logical_test.clj diff --git a/test/vault/kvv2_test.clj b/test/vault/kvv2_test.clj index bb6c65d..46ff84a 100644 --- a/test/vault/kvv2_test.clj +++ b/test/vault/kvv2_test.clj @@ -6,7 +6,7 @@ ExceptionInfo))) -(deftest write-config-test +(deftest write-config!-test (let [mount "mount" token-passed-in "fake-token" vault-url "https://vault.example.amperity.com" @@ -14,14 +14,15 @@ new-config {:max_versions 5 :cas_require false :delete_version_after}] + (vault.core/authenticate! client :token {:client-token token-passed-in}) (testing "Config can be updated with valid call" (with-redefs [clj-http.client/post - (fn [url req] - (is (= (str vault-url "/v1/" mount "/config") url)) + (fn [req] + (is (= (str vault-url "/v1/" mount "/config") (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) (is (= new-config (:data req))))] - (is (true? (vault-kv/write-config client mount new-config))))))) + (is (true? (vault-kv/write-config! client mount new-config))))))) (deftest read-config-test @@ -32,6 +33,7 @@ token-passed-in "fake-token" vault-url "https://vault.example.amperity.com" client (vault.core/new-client vault-url)] + (vault.core/authenticate! client :token token-passed-in) (testing "Config can be read with valid call" (with-redefs [clj-http.client/get @@ -52,6 +54,7 @@ token-passed-in "fake-token" vault-url "https://vault.example.amperity.com" client (vault.core/new-client vault-url)] + (vault.core/authenticate! client :token token-passed-in) (testing "Read responds correctly if secret is successfully located" (with-redefs [clj-http.client/get @@ -74,7 +77,7 @@ (is (= {:status 404} (ex-data e))))))))) -(deftest write-test +(deftest write!-test (let [create-success {:data {:created_time "2018-03-22T02:24:06.945319214Z" :deletion_time "" :destroyed false @@ -86,6 +89,7 @@ token-passed-in "fake-token" vault-url "https://vault.example.amperity.com" client (vault.core/new-client vault-url)] + (vault.core/authenticate! client :token token-passed-in) (testing "Write writes and returns true upon success" (with-redefs [clj-http.client/post @@ -96,7 +100,7 @@ :options options} (:data req))) create-success)] - (is (true? (vault-kv/write client vault-url write-data options))))) + (is (true? (vault-kv/write! client vault-url write-data options))))) (testing "Write returns false upon failure" (with-redefs [clj-http.client/post @@ -106,5 +110,6 @@ (is (= {:data write-data :options options} (:data req))) - {:errors []})] - (is (false? (vault-kv/write client vault-url write-data options))))))) + {:errors [] + :status 404})] + (is (false? (vault-kv/write! client vault-url write-data options))))))) diff --git a/test/vault/logical_test.clj b/test/vault/logical_test.clj new file mode 100644 index 0000000..d78fbd6 --- /dev/null +++ b/test/vault/logical_test.clj @@ -0,0 +1,102 @@ +(ns vault.logical-test + (:require [clojure.test :refer [is testing deftest]] + [vault.core :as vault-logical] + ;; TODO: Move vault-logical to its own ns + [vault.client.http]) + (:import (clojure.lang ExceptionInfo))) + + +(deftest list-secrets-test + (let [path "path/passed/in" + token-passed-in "fake-token" + vault-url "https://vault.example.amperity.com" + client (vault.core/new-client vault-url) + response {:auth nil + :data {:keys ["foo" "foo/"]} + :lease_duration 2764800 + :lease-id "" + :renewable false}] + (vault.core/authenticate! client :token token-passed-in) + (testing "List secrets works with valid call" + (with-redefs + [clj-http.client/request + (fn [req] + (is (= (str vault-url "/v1/" path) (:url req))) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) + (is (true? (-> req :query-params :list))) + {:body response})] + (is (= ["foo" "foo/"] + (vault-logical/list-secrets client path))))))) + + +(deftest read-secret-test + (let [lookup-response-valid-path {:auth nil + :data {:foo "bar" + :ttl "1h"} + :lease_duration 3600 + :lease_id "" + :renewable false} + path-passed-in "path/passed/in" + path-passed-in2 "path/passed/in2" + token-passed-in "fake-token" + vault-url "https://vault.example.amperity.com" + client (vault.core/new-client vault-url)] + (vault.core/authenticate! client :token token-passed-in) + (testing "Read responds correctly if secret is successfully located" + (with-redefs + [clj-http.client/request + (fn [req] + (is (= (str vault-url "/v1/" path-passed-in) (:url req))) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) + {:body lookup-response-valid-path})] + (is (= {:foo "bar" :ttl "1h"} (vault-logical/read-secret client path-passed-in))))) + (testing "Read responds correctly if no secret is found" + (with-redefs + [clj-http.client/request + (fn [req] + (is (= (str vault-url "/v1/" path-passed-in2) (:url req))) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) + (throw (ex-info "not found" {:error [] :status 404})))] + (try + (vault-logical/read-secret client path-passed-in2) + (catch ExceptionInfo e + (is (= {:errors nil + :status 404 + :type :vault.client.http/api-error} + (ex-data e))))))))) + + +(deftest write-test + (let [create-success {:data {:created_time "2018-03-22T02:24:06.945319214Z" + :deletion_time "" + :destroyed false + :version 1}} + write-data {:foo "bar" + :zip "zap"} + path-passed-in "path/passed/in" + token-passed-in "fake-token" + vault-url "https://vault.example.amperity.com" + client (vault.core/new-client vault-url)] + (vault.core/authenticate! client :token token-passed-in) + (testing "Write writes and returns true upon success" + (with-redefs + [clj-http.client/request + (fn [req] + (is (= (str vault-url "/v1/" path-passed-in) (:url req))) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) + (is (= write-data (:form-params req))) + {:body create-success + :status 204})] + (is (true? (vault-logical/write-secret! client path-passed-in write-data))))) + (testing "Write returns false upon failure" + (with-redefs + [clj-http.client/request + (fn [req] + (is (= (str vault-url "/v1/" path-passed-in) (:url req))) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) + (is (= write-data + (:form-params req))) + {:errors [] + :status 400})] + (is (false? (vault-logical/write-secret! client path-passed-in write-data))))))) + From 39242ca743f85d23837c6ec019e8c8159e297027 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Thu, 7 Nov 2019 11:01:13 -0800 Subject: [PATCH 05/53] Added tests for http methods --- test/vault/logical_test.clj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/vault/logical_test.clj b/test/vault/logical_test.clj index d78fbd6..0f4e3d7 100644 --- a/test/vault/logical_test.clj +++ b/test/vault/logical_test.clj @@ -21,6 +21,7 @@ (with-redefs [clj-http.client/request (fn [req] + (is (= :get (:method req))) (is (= (str vault-url "/v1/" path) (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) (is (true? (-> req :query-params :list))) @@ -46,6 +47,7 @@ (with-redefs [clj-http.client/request (fn [req] + (is (= :get (:method req))) (is (= (str vault-url "/v1/" path-passed-in) (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) {:body lookup-response-valid-path})] @@ -82,6 +84,7 @@ (with-redefs [clj-http.client/request (fn [req] + (is (= :post (:method req))) (is (= (str vault-url "/v1/" path-passed-in) (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) (is (= write-data (:form-params req))) @@ -92,6 +95,7 @@ (with-redefs [clj-http.client/request (fn [req] + (is (= :post (:method req))) (is (= (str vault-url "/v1/" path-passed-in) (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) (is (= write-data From 013d935c67217e08e33b1c406619f70f5a1636f7 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Thu, 7 Nov 2019 11:07:58 -0800 Subject: [PATCH 06/53] Changed tests to mock request directoy --- test/vault/kvv2_test.clj | 57 +++++++++++++++++++++++----------------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/test/vault/kvv2_test.clj b/test/vault/kvv2_test.clj index 46ff84a..fb03d71 100644 --- a/test/vault/kvv2_test.clj +++ b/test/vault/kvv2_test.clj @@ -1,6 +1,7 @@ (ns vault.kvv2-test (:require - [clojure.test :refer [testing deftest is]]) + [clojure.test :refer [testing deftest is]] + [vault.client.http]) (:import (clojure.lang ExceptionInfo))) @@ -17,11 +18,13 @@ (vault.core/authenticate! client :token {:client-token token-passed-in}) (testing "Config can be updated with valid call" (with-redefs - [clj-http.client/post + [clj-http.client/request (fn [req] + (is (= :post (:method req))) (is (= (str vault-url "/v1/" mount "/config") (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) - (is (= new-config (:data req))))] + (is (= new-config (:data req))) + {:status 200})] (is (true? (vault-kv/write-config! client mount new-config))))))) @@ -36,11 +39,12 @@ (vault.core/authenticate! client :token token-passed-in) (testing "Config can be read with valid call" (with-redefs - [clj-http.client/get - (fn [url opts] - (is (= (str vault-url "/v1/" mount "/config") url)) - (is (= token-passed-in (get (:headers opts) "X-Vault-Token"))) - {:data config})] + [clj-http.client/request + (fn [req] + (is (= :get (:method req))) + (is (= (str vault-url "/v1/" mount "/config") (:url req))) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) + {:body {:data config}})] (is (= config (vault-kv/read-config client mount))))))) @@ -57,18 +61,20 @@ (vault.core/authenticate! client :token token-passed-in) (testing "Read responds correctly if secret is successfully located" (with-redefs - [clj-http.client/get - (fn [url opts] - (is (= (str vault-url "/v1/data/" path-passed-in) url)) - (is (= token-passed-in (get (:headers opts) "X-Vault-Token"))) - lookup-response-valid-path)] + [clj-http.client/request + (fn [req] + (is (= :get (:method req))) + (is (= (str vault-url "/v1/data/" path-passed-in) (:url req))) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) + {:body lookup-response-valid-path})] (is (= {:foo "bar"} (vault-kv/read client vault-url))))) (testing "Read responds correctly if no secret is found" (with-redefs - [clj-http.client/get - (fn [url opts] - (is (= (str vault-url "/v1/data/" path-passed-in) url)) - (is (= token-passed-in (get (:headers opts) "X-Vault-Token"))) + [clj-http.client/request + (fn [req] + (is (= :get (:method req))) + (is (= (str vault-url "/v1/data/" path-passed-in) (:url req))) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) {:errors []})] (try (vault-kv/read client vault-url) @@ -92,20 +98,23 @@ (vault.core/authenticate! client :token token-passed-in) (testing "Write writes and returns true upon success" (with-redefs - [clj-http.client/post - (fn [url req] - (is (= (str vault-url "/v1/data/" path-passed-in) url)) + [clj-http.client/request + (fn [req] + (is (= :post (:method req))) + (is (= (str vault-url "/v1/data/" path-passed-in) (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) (is (= {:data write-data :options options} (:data req))) - create-success)] + {:body create-success + :status 200})] (is (true? (vault-kv/write! client vault-url write-data options))))) (testing "Write returns false upon failure" (with-redefs - [clj-http.client/post - (fn [url req] - (is (= (str vault-url "/v1/data/" path-passed-in) url)) + [clj-http.client/request + (fn [req] + (is (= :post (:method req))) + (is (= (str vault-url "/v1/data/" path-passed-in) (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) (is (= {:data write-data :options options} From e1debee47aed34742eef298a311cdd5c711d8686 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Thu, 7 Nov 2019 11:29:31 -0800 Subject: [PATCH 07/53] Moved logical lookup to its own ns --- src/vault/client/http.clj | 3 ++- src/vault/client/mock.clj | 5 +++-- src/vault/core.clj | 36 ---------------------------------- src/vault/env.clj | 5 +++-- src/vault/secrets/logical.clj | 37 +++++++++++++++++++++++++++++++++++ test/vault/logical_test.clj | 3 +-- 6 files changed, 46 insertions(+), 43 deletions(-) create mode 100644 src/vault/secrets/logical.clj diff --git a/src/vault/client/http.clj b/src/vault/client/http.clj index 3338eb7..bcb1de9 100644 --- a/src/vault/client/http.clj +++ b/src/vault/client/http.clj @@ -8,6 +8,7 @@ [com.stuartsierra.component :as component] [vault.core :as vault] [vault.lease :as lease] + [vault.secrets.logical :as vault-logical] [vault.timer :as timer]) (:import java.security.MessageDigest @@ -523,7 +524,7 @@ this) - vault/SecretClient + vault-logical/LogicalSecretClient (list-secrets [this path] diff --git a/src/vault/client/mock.clj b/src/vault/client/mock.clj index 2852c03..1126362 100644 --- a/src/vault/client/mock.clj +++ b/src/vault/client/mock.clj @@ -4,7 +4,8 @@ [clojure.java.io :as io] [clojure.string :as str] [clojure.tools.logging :as log] - [vault.core :as vault]) + [vault.core :as vault] + [vault.secrets.logical :as vault-logical]) (:import java.net.URI java.text.SimpleDateFormat @@ -152,7 +153,7 @@ this) - vault/SecretClient + vault-logical/LogicalSecretClient (list-secrets [this path] diff --git a/src/vault/core.clj b/src/vault/core.clj index 1cb29b1..b5d0812 100644 --- a/src/vault/core.clj +++ b/src/vault/core.clj @@ -131,42 +131,6 @@ "Removes the watch function registered with the given key, if any.")) -(defprotocol SecretClient - "Basic API for listing, reading, and writing secrets." - - (list-secrets - [client path] - "List the secrets located under a path.") - - (read-secret - [client path] - [client path opts] - "Reads a secret from a path. Returns the full map of stored secret data if - the secret exists, or throws an exception if not. - - Additional options may include: - - - `:not-found` - If the requested path is not found, return this value instead of throwing - an exception. - - `:renew` - Whether or not to renew this secret when the lease is near expiry. - - `:rotate` - Whether or not to rotate this secret when the lease is near expiry and - cannot be renewed. - - `:force-read` - Force the secret to be read from the server even if there is a valid lease cached.") - - (write-secret! - [client path data] - "Writes secret data to a path. `data` should be a map. Returns a - boolean indicating whether the write was successful.") - - (delete-secret! - [client path] - "Removes secret data from a path. Returns a boolean indicating whether the - deletion was successful.")) - (defprotocol WrappingClient "Secret wrapping API for exchanging limited-use tokens for wrapped data." diff --git a/src/vault/env.clj b/src/vault/env.clj index 4e33b42..a2d78c8 100644 --- a/src/vault/env.clj +++ b/src/vault/env.clj @@ -11,7 +11,8 @@ ; For extensions to vault.core/new-client multimethod. [vault.client.http] [vault.client.mock] - [vault.core :as vault])) + [vault.core :as vault] + [vault.secrets.logical :as vault-logical])) (def vault-prefix "vault:") @@ -98,7 +99,7 @@ (throw (ex-info "Cannot resolve secret without initialized client" {:uri vault-uri}))) (let [[path attr] (str/split (subs vault-uri (count vault-prefix)) #"#") - secret (vault/read-secret client path) + secret (vault-logical/read-secret client path) attr (or (keyword attr) :data) value (get secret attr)] (when (nil? value) diff --git a/src/vault/secrets/logical.clj b/src/vault/secrets/logical.clj new file mode 100644 index 0000000..3353638 --- /dev/null +++ b/src/vault/secrets/logical.clj @@ -0,0 +1,37 @@ +(ns vault.secrets.logical) + +(defprotocol LogicalSecretClient + "Basic API for listing, reading, and writing secrets." + + (list-secrets + [client path] + "List the secrets located under a path.") + + (read-secret + [client path] + [client path opts] + "Reads a secret from a path. Returns the full map of stored secret data if + the secret exists, or throws an exception if not. + + Additional options may include: + + - `:not-found` + If the requested path is not found, return this value instead of throwing + an exception. + - `:renew` + Whether or not to renew this secret when the lease is near expiry. + - `:rotate` + Whether or not to rotate this secret when the lease is near expiry and + cannot be renewed. + - `:force-read` + Force the secret to be read from the server even if there is a valid lease cached.") + + (write-secret! + [client path data] + "Writes secret data to a path. `data` should be a map. Returns a + boolean indicating whether the write was successful.") + + (delete-secret! + [client path] + "Removes secret data from a path. Returns a boolean indicating whether the + deletion was successful.")) diff --git a/test/vault/logical_test.clj b/test/vault/logical_test.clj index 0f4e3d7..a7a24cf 100644 --- a/test/vault/logical_test.clj +++ b/test/vault/logical_test.clj @@ -1,7 +1,6 @@ (ns vault.logical-test (:require [clojure.test :refer [is testing deftest]] - [vault.core :as vault-logical] - ;; TODO: Move vault-logical to its own ns + [vault.secrets.logical :as vault-logical] [vault.client.http]) (:import (clojure.lang ExceptionInfo))) From 9df3a782cf02bce899f1376a64ca1785aaf297bb Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Thu, 7 Nov 2019 11:33:15 -0800 Subject: [PATCH 08/53] cljstyle fix imports on logical test --- test/vault/logical_test.clj | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/test/vault/logical_test.clj b/test/vault/logical_test.clj index a7a24cf..c8a8c4c 100644 --- a/test/vault/logical_test.clj +++ b/test/vault/logical_test.clj @@ -1,8 +1,11 @@ (ns vault.logical-test - (:require [clojure.test :refer [is testing deftest]] - [vault.secrets.logical :as vault-logical] - [vault.client.http]) - (:import (clojure.lang ExceptionInfo))) + (:require + [clojure.test :refer [is testing deftest]] + [vault.client.http] + [vault.secrets.logical :as vault-logical]) + (:import + (clojure.lang + ExceptionInfo))) (deftest list-secrets-test From 98d851478a1fe3d56e303bc5f19b47cc8c2ce5e5 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Thu, 7 Nov 2019 17:27:27 -0800 Subject: [PATCH 09/53] Refactored code to allow for multiple secret engs with the same functions --- dev/user.clj | 3 +- src/vault/client/http.clj | 77 +++++--------------- src/vault/client/mock.clj | 18 ++--- src/vault/core.clj | 2 - src/vault/env.clj | 14 ++-- src/vault/lease.clj | 3 - src/vault/secret_engines.clj | 39 ++++++++++ src/vault/secrets/dispatch.clj | 42 +++++++++++ src/vault/secrets/kvv2.clj | 1 + src/vault/secrets/logical.clj | 125 +++++++++++++++++++++++---------- 10 files changed, 201 insertions(+), 123 deletions(-) create mode 100644 src/vault/secret_engines.clj create mode 100644 src/vault/secrets/dispatch.clj create mode 100644 src/vault/secrets/kvv2.clj diff --git a/dev/user.clj b/dev/user.clj index b16783e..b061892 100644 --- a/dev/user.clj +++ b/dev/user.clj @@ -5,8 +5,9 @@ [clojure.string :as str] [clojure.tools.namespace.repl :refer [refresh]] [com.stuartsierra.component :as component] + [vault.client.http] + [vault.client.mock] [vault.core :as vault] - (vault.client http mock) [vault.env :as venv] [vault.lease :as lease])) diff --git a/src/vault/client/http.clj b/src/vault/client/http.clj index bcb1de9..4b6c386 100644 --- a/src/vault/client/http.clj +++ b/src/vault/client/http.clj @@ -8,11 +8,13 @@ [com.stuartsierra.component :as component] [vault.core :as vault] [vault.lease :as lease] - [vault.secrets.logical :as vault-logical] + [vault.secret-engines :as engines] + [vault.secrets.dispatch :as engine-dispatch] [vault.timer :as timer]) (:import java.security.MessageDigest - org.apache.commons.codec.binary.Hex)) + (org.apache.commons.codec.binary + Hex))) ;; ## API Utilities @@ -40,7 +42,7 @@ (Hex/encodeHexString (.digest hasher)))) -(defn- clean-body +(defn clean-body "Cleans up a response from the Vault API by rewriting some keywords and dropping extraneous information. Note that this changes the `:data` in the response to the original result to preserve accuracy." @@ -93,7 +95,7 @@ resp)))) -(defn- api-request +(defn api-request "Helper method to perform an API request with common headers and values. Currently always uses API version `v1`. The `path` should be relative to the version root." @@ -133,7 +135,6 @@ :as :json}))) - ;; ## Authentication Methods (defn ^:no-doc api-auth! @@ -158,7 +159,7 @@ (defmethod authenticate* :default [client auth-type _] (throw (ex-info (str "Unsupported auth-type " (pr-str auth-type)) - {:auth-type auth-type}))) + {:auth-type auth-type}))) (defmethod authenticate* :token @@ -524,68 +525,23 @@ this) - vault-logical/LogicalSecretClient + engines/SecretEngine (list-secrets - [this path] - (let [response (api-request - this :get path - {:query-params {:list true}}) - data (get-in response [:body :data :keys])] - (log/debugf "List %s (%d results)" path (count data)) - data)) - + [this path eng] + (engine-dispatch/list-secrets* this path eng)) (read-secret - [this path] - (.read-secret this path nil)) - - - (read-secret - [this path opts] - (or (when-let [lease (and (not (:force-read opts)) - (lease/lookup leases path))] - (when-not (lease/expired? lease) - (:data lease))) - (try - (let [response (api-request this :get path {}) - info (assoc (clean-body response) - :path path - :renew (:renew opts) - :rotate (:rotate opts))] - (log/debugf "Read %s (valid for %d seconds)" - path (:lease-duration info)) - (lease/update! leases info) - (:data info)) - (catch clojure.lang.ExceptionInfo ex - (if (and (contains? opts :not-found) - (= ::api-error (:type (ex-data ex))) - (= 404 (:status (ex-data ex)))) - (:not-found opts) - (throw ex)))))) - + [this path opts eng] + (engine-dispatch/read-secret* this path opts eng)) (write-secret! - [this path data] - (let [response (api-request - this :post path - {:form-params data - :content-type :json})] - (log/debug "Wrote secret" path) - (lease/remove-path! leases path) - (case (int (:status response -1)) - 204 true - 200 (:body response) - false))) - + [this path data eng] + (engine-dispatch/write-secret!* this path data eng)) (delete-secret! - [this path] - (let [response (api-request this :delete path {})] - (log/debug "Deleted secret" path) - (lease/remove-path! leases path) - (= 204 (:status response)))) - + [this path eng] + (engine-dispatch/delete-secret!* this path eng)) vault/WrappingClient @@ -609,7 +565,6 @@ {:body (:body response)})))))) - ;; ## Constructors ;; Privatize automatic constructors. diff --git a/src/vault/client/mock.clj b/src/vault/client/mock.clj index 1126362..571a64a 100644 --- a/src/vault/client/mock.clj +++ b/src/vault/client/mock.clj @@ -5,7 +5,7 @@ [clojure.string :as str] [clojure.tools.logging :as log] [vault.core :as vault] - [vault.secrets.logical :as vault-logical]) + [vault.secret-engines :as engines]) (:import java.net.URI java.text.SimpleDateFormat @@ -153,20 +153,15 @@ this) - vault-logical/LogicalSecretClient + engines/SecretEngine (list-secrets - [this path] + [this path eng] (filter #(str/starts-with? % (str path)) (keys @memory))) (read-secret - [this path] - (.read-secret this path nil)) - - - (read-secret - [this path opts] + [this path opts eng] (or (get @memory path) (if (contains? opts :not-found) (:not-found opts) @@ -175,13 +170,13 @@ (write-secret! - [this path data] + [this path data eng] (swap! memory assoc path data) true) (delete-secret! - [this path] + [this path eng] (swap! memory dissoc path) true) @@ -205,7 +200,6 @@ (throw (ex-info "Unknown wrap-token used" {}))))) - ;; ## Constructors ;; Privatize automatic constructors. diff --git a/src/vault/core.clj b/src/vault/core.clj index b5d0812..b311b03 100644 --- a/src/vault/core.clj +++ b/src/vault/core.clj @@ -131,7 +131,6 @@ "Removes the watch function registered with the given key, if any.")) - (defprotocol WrappingClient "Secret wrapping API for exchanging limited-use tokens for wrapped data." @@ -145,7 +144,6 @@ "Returns the original response wrapped by the given token.")) - ;; ## Client Construction (defmulti new-client diff --git a/src/vault/env.clj b/src/vault/env.clj index a2d78c8..c774ed8 100644 --- a/src/vault/env.clj +++ b/src/vault/env.clj @@ -77,17 +77,17 @@ (let [client (vault/new-client addr)] (cond (env :vault-token) - (vault/authenticate! client :token (env :vault-token)) + (vault/authenticate! client :token (env :vault-token)) (env :vault-wrap-token) - (vault/authenticate! client :wrap-token (env :vault-wrap-token)) + (vault/authenticate! client :wrap-token (env :vault-wrap-token)) (and (env :vault-app-id) (env :vault-user-id)) - (vault/authenticate! client :app-id {:app (env :vault-app-id) - :user (env :vault-user-id)}) + (vault/authenticate! client :app-id {:app (env :vault-app-id) + :user (env :vault-user-id)}) (and (env :vault-role-id) (env :vault-secret-id)) - (vault/authenticate! client :app-role {:role-id (env :vault-role-id) - :secret-id (env :vault-secret-id)}) + (vault/authenticate! client :app-role {:role-id (env :vault-role-id) + :secret-id (env :vault-secret-id)}) :else - (log/warn "No authentication information found in environment!")) + (log/warn "No authentication information found in environment!")) client))) diff --git a/src/vault/lease.clj b/src/vault/lease.clj index 0a0b6fa..ab0f6fb 100644 --- a/src/vault/lease.clj +++ b/src/vault/lease.clj @@ -14,7 +14,6 @@ (Instant/now)) - ;; ## Lease Construction (defn auth-lease @@ -39,7 +38,6 @@ (some? (:rotate info)) (assoc ::rotate (boolean (:rotate info))))) - ;; ## Lease Logic (defn leased? @@ -70,7 +68,6 @@ (expires-within? lease 0)) - ;; ## Secret Storage (defn new-store diff --git a/src/vault/secret_engines.clj b/src/vault/secret_engines.clj new file mode 100644 index 0000000..a501ce9 --- /dev/null +++ b/src/vault/secret_engines.clj @@ -0,0 +1,39 @@ +(ns vault.secret-engines) + + +(defprotocol SecretEngine + "Basic API for listing, reading, and writing secrets. + + `eng` is a keyword representing the secret engine/mount" + + (list-secrets + [client path eng] + "List the secrets located under a path.") + + (read-secret + [client path opts eng] + "Reads a secret from a path. Returns the full map of stored secret data if + the secret exists, or throws an exception if not. + + Additional options may include: + + - `:not-found` + If the requested path is not found, return this value instead of throwing + an exception. + - `:renew` + Whether or not to renew this secret when the lease is near expiry. + - `:rotate` + Whether or not to rotate this secret when the lease is near expiry and + cannot be renewed. + - `:force-read` + Force the secret to be read from the server even if there is a valid lease cached.") + + (write-secret! + [client path data eng] + "Writes secret data to a path. `data` should be a map. Returns a + boolean indicating whether the write was successful.") + + (delete-secret! + [client path eng] + "Removes secret data from a path. Returns a boolean indicating whether the + deletion was successful.")) diff --git a/src/vault/secrets/dispatch.clj b/src/vault/secrets/dispatch.clj new file mode 100644 index 0000000..53f3215 --- /dev/null +++ b/src/vault/secrets/dispatch.clj @@ -0,0 +1,42 @@ +; Handles dispatch related to the functions from the secret engines protocol +(ns vault.secrets.dispatch) + +; -- list --------------------------------------------------------------------- + +(defmulti list-secrets* + (fn dispatch [client path eng] eng)) + + +(defmethod list-secrets* :default + [client path eng] + (throw (ex-info "list not supported by the secret engine" {:path path :engine eng}))) + +; -- read --------------------------------------------------------------------- + +(defmulti read-secret* + (fn dispatch [client path opts eng] eng)) + + +(defmethod read-secret* :default + [client path opts eng] + (throw (ex-info "read not supported by the secret engine" {:path path :engine eng}))) + +; -- write! ------------------------------------------------------------------- + +(defmulti write-secret!* + (fn [client path data eng] eng)) + + +(defmethod write-secret!* :default + [client path data eng] + (throw (ex-info "write! not supported by the secret engine" {:path path :engine eng}))) + +; -- delete! ------------------------------------------------------------------ + +(defmulti delete-secret!* + (fn [client path eng] eng)) + + +(defmethod delete-secret!* :default + [client path data eng] + (throw (ex-info "delete! not supported by the secret engine" {:path path :engine eng}))) diff --git a/src/vault/secrets/kvv2.clj b/src/vault/secrets/kvv2.clj new file mode 100644 index 0000000..e4ca89a --- /dev/null +++ b/src/vault/secrets/kvv2.clj @@ -0,0 +1 @@ +(ns vault.secrets.kvv2) diff --git a/src/vault/secrets/logical.clj b/src/vault/secrets/logical.clj index 3353638..f72bb94 100644 --- a/src/vault/secrets/logical.clj +++ b/src/vault/secrets/logical.clj @@ -1,37 +1,88 @@ -(ns vault.secrets.logical) - -(defprotocol LogicalSecretClient - "Basic API for listing, reading, and writing secrets." - - (list-secrets - [client path] - "List the secrets located under a path.") - - (read-secret - [client path] - [client path opts] - "Reads a secret from a path. Returns the full map of stored secret data if - the secret exists, or throws an exception if not. - - Additional options may include: - - - `:not-found` - If the requested path is not found, return this value instead of throwing - an exception. - - `:renew` - Whether or not to renew this secret when the lease is near expiry. - - `:rotate` - Whether or not to rotate this secret when the lease is near expiry and - cannot be renewed. - - `:force-read` - Force the secret to be read from the server even if there is a valid lease cached.") - - (write-secret! - [client path data] - "Writes secret data to a path. `data` should be a map. Returns a - boolean indicating whether the write was successful.") - - (delete-secret! - [client path] - "Removes secret data from a path. Returns a boolean indicating whether the - deletion was successful.")) +(ns vault.secrets.logical + (:require + [clojure.tools.logging :as log] + [vault.client.http :as http-client] + [vault.lease :as lease] + [vault.secret-engines :as engine] + [vault.secrets.dispatch :refer [list-secrets* read-secret* write-secret!* delete-secret!*]]) + (:import + (clojure.lang + ExceptionInfo))) + + +(defn list-secrets + [client path] + (engine/list-secrets client path :logical)) + + +(defmethod list-secrets* :logical + [client path _] + (let [response (http-client/api-request + client :get path + {:query-params {:list true}}) + data (get-in response [:body :data :keys])] + (log/debugf "List %s (%d results)" path (count data)) + data)) + + +(defn read-secret + ([client path opts] + (engine/read-secret client path opts :logical)) + ([client path] + (read-secret client path nil))) + + +(defmethod read-secret* :logical + [client path opts _] + (or (when-let [lease (and (not (:force-read opts)) + (lease/lookup (:leases client) path))] + (when-not (lease/expired? lease) + (:data lease))) + (try + (let [response (http-client/api-request client :get path {}) + info (assoc (http-client/clean-body response) + :path path + :renew (:renew opts) + :rotate (:rotate opts))] + (log/debugf "Read %s (valid for %d seconds)" + path (:lease-duration info)) + (lease/update! (:leases client) info) + (:data info)) + (catch ExceptionInfo ex + (if (and (contains? opts :not-found) + (= ::api-error (:type (ex-data ex))) + (= 404 (:status (ex-data ex)))) + (:not-found opts) + (throw ex)))))) + + +(defn write-secret! + [client path data] + (engine/write-secret! client path data :logical)) + + +(defmethod write-secret!* :logical + [client path data _] + (let [response (http-client/api-request + client :post path + {:form-params data + :content-type :json})] + (log/debug "Wrote secret" path) + (lease/remove-path! (:leases client) path) + (case (int (:status response -1)) + 204 true + 200 (:body response) + false))) + + +(defn delete-secret! + [client path] + (engine/delete-secret! client path :logical)) + + +(defmethod delete-secret!* :logical + [client path _] + (let [response (http-client/api-request client :delete path {})] + (log/debug "Deleted secret" path) + (lease/remove-path! (:leases client) path) + (= 204 (:status response)))) From 726a06056638694f9531004588a2b77b52bbc794 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Thu, 7 Nov 2019 17:36:44 -0800 Subject: [PATCH 10/53] Fix up broken tests --- test/vault/client/http_test.clj | 7 ++++--- test/vault/{ => secrets}/kvv2_test.clj | 0 test/vault/{ => secrets}/logical_test.clj | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) rename test/vault/{ => secrets}/kvv2_test.clj (100%) rename test/vault/{ => secrets}/logical_test.clj (99%) diff --git a/test/vault/client/http_test.clj b/test/vault/client/http_test.clj index a34816b..dd8a0e7 100644 --- a/test/vault/client/http_test.clj +++ b/test/vault/client/http_test.clj @@ -2,7 +2,8 @@ (:require [clojure.test :refer :all] [vault.client.http :refer [http-client] :as h] - [vault.core :as vault])) + [vault.core :as vault] + [vault.secrets.logical :as vault-logical])) (def example-url "https://vault.example.com") @@ -19,10 +20,10 @@ (deftest http-read-checks (let [client (http-client example-url)] (is (thrown? IllegalArgumentException - (vault/read-secret client nil)) + (vault-logical/read-secret client nil)) "should throw an exception on non-string path") (is (thrown? IllegalStateException - (vault/read-secret client "secret/foo/bar")) + (vault-logical/read-secret client "secret/foo/bar")) "should throw an exception on unauthenticated client"))) diff --git a/test/vault/kvv2_test.clj b/test/vault/secrets/kvv2_test.clj similarity index 100% rename from test/vault/kvv2_test.clj rename to test/vault/secrets/kvv2_test.clj diff --git a/test/vault/logical_test.clj b/test/vault/secrets/logical_test.clj similarity index 99% rename from test/vault/logical_test.clj rename to test/vault/secrets/logical_test.clj index c8a8c4c..c40c22d 100644 --- a/test/vault/logical_test.clj +++ b/test/vault/secrets/logical_test.clj @@ -1,4 +1,4 @@ -(ns vault.logical-test +(ns vault.secrets.logical-test (:require [clojure.test :refer [is testing deftest]] [vault.client.http] From 3a69e45245689fc4833dfcecd2e92bb3352873d1 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Tue, 12 Nov 2019 17:41:00 -0800 Subject: [PATCH 11/53] Implemented reading secrets using kvv2 and other methods * Reading secrets using kvv2 passing initial test --- src/vault/client/http.clj | 9 +++++ src/vault/secret_engines.clj | 20 ++++++++++- src/vault/secrets/dispatch.clj | 23 ++++++++++++- src/vault/secrets/kvv2.clj | 59 +++++++++++++++++++++++++++++++- test/vault/secrets/kvv2_test.clj | 40 +++++++++++++--------- 5 files changed, 132 insertions(+), 19 deletions(-) diff --git a/src/vault/client/http.clj b/src/vault/client/http.clj index 4b6c386..6975737 100644 --- a/src/vault/client/http.clj +++ b/src/vault/client/http.clj @@ -526,6 +526,7 @@ engines/SecretEngine + ;; This dispatch allows mocking because all requests go through the client (list-secrets [this path eng] @@ -543,6 +544,14 @@ [this path eng] (engine-dispatch/delete-secret!* this path eng)) + (write-config! + [this path data eng] + (engine-dispatch/write-config!* this path data eng)) + + (read-config + [this path eng] + (engine-dispatch/read-config* this path eng)) + vault/WrappingClient (wrap! diff --git a/src/vault/secret_engines.clj b/src/vault/secret_engines.clj index a501ce9..fe3d732 100644 --- a/src/vault/secret_engines.clj +++ b/src/vault/secret_engines.clj @@ -36,4 +36,22 @@ (delete-secret! [client path eng] "Removes secret data from a path. Returns a boolean indicating whether the - deletion was successful.")) + deletion was successful.") + + (write-config! + [client path data eng] + "Writes configurations at the given path + + Data is the body of a request specifying: + - `max_versions` – The number (as an int) of versions to keep per key. + This value applies to all keys, but a key's metadata setting can overwrite this value. + Once a key has more than the configured allowed versions the oldest version will + be permanently deleted. Defaults to 10. + - `can-required` - If true all keys will require the cas parameter to be set on all write requests. + - `delete_versions_after` - String that pecifies the length of time before a version is deleted. + Accepts Go duration format string.") + +(read-config + [client path eng] + "Reads configurations at the given path")) + diff --git a/src/vault/secrets/dispatch.clj b/src/vault/secrets/dispatch.clj index 53f3215..a5a4e43 100644 --- a/src/vault/secrets/dispatch.clj +++ b/src/vault/secrets/dispatch.clj @@ -38,5 +38,26 @@ (defmethod delete-secret!* :default - [client path data eng] + [client path eng] (throw (ex-info "delete! not supported by the secret engine" {:path path :engine eng}))) + + +; -- write-config! ------------------------------------------------------------------ + +(defmulti write-config!* + (fn [client path data eng] eng)) + + +(defmethod write-config!* :default + [client path data eng] + (throw (ex-info "write-config! not supported by the secret engine" {:path path :engine eng}))) + +; -- read-config ------------------------------------------------------------------ + +(defmulti read-config* + (fn [client path eng] eng)) + + +(defmethod read-config* :default + [client path eng] + (throw (ex-info "read-config not supported by the secret engine" {:path path :engine eng}))) diff --git a/src/vault/secrets/kvv2.clj b/src/vault/secrets/kvv2.clj index e4ca89a..afe2ba7 100644 --- a/src/vault/secrets/kvv2.clj +++ b/src/vault/secrets/kvv2.clj @@ -1 +1,58 @@ -(ns vault.secrets.kvv2) +(ns vault.secrets.kvv2 + (:require [vault.secret-engines :as engine] + [vault.client.http :as http-client] + [vault.lease :as lease] + [clojure.tools.logging :as log] + [clojure.string :as str] + [vault.secrets.dispatch :refer [read-secret* write-config!* read-config*]] + [vault.secrets.logical :as vault-logical]) + (:import (clojure.lang ExceptionInfo))) + +(defn read-secret + ([client mount path opts] + (engine/read-secret client (str mount "/data/" path) opts :kvv2)) + ([client mount path] + (read-secret client mount path nil))) + +(defmethod read-secret* :kvv2 + [client path opts _] + (try + (:data (vault-logical/read-secret client path (dissoc opts :not-found))) + + (catch ExceptionInfo ex + (if (and (contains? opts :not-found) + (= ::api-error (:type (ex-data ex))) + (= 404 (:status (ex-data ex)))) + ;(:not-found opts) + (throw ex))))) + +(defn write-secret! + [client mount path data] + (engine/write-secret! client (str mount "/data/" path) data :logical)) + + +(defn write-config! + [client mount data] + (engine/write-config! client (str mount "/config") data :kvv2)) + + +(defmethod write-config!* :kvv2 + [client path data _] + (let [response (http-client/api-request + client :post path + {:form-params data + :content-type :json})] + (log/debug "Wrote config" path) + (case (int (:status response -1)) + 204 true + 200 (:body response) + false))) + +(defn read-config + [client mount] + (-> (engine/read-config client (str mount "/config") :kvv2) :body :data)) + +(defmethod read-config* :kvv2 + [client path _] + (http-client/api-request client :get path {})) + diff --git a/test/vault/secrets/kvv2_test.clj b/test/vault/secrets/kvv2_test.clj index fb03d71..bf0d47f 100644 --- a/test/vault/secrets/kvv2_test.clj +++ b/test/vault/secrets/kvv2_test.clj @@ -1,7 +1,8 @@ -(ns vault.kvv2-test +(ns vault.secrets.kvv2-test (:require [clojure.test :refer [testing deftest is]] - [vault.client.http]) + [vault.client.http] + [vault.secrets.kvv2 :as vault-kv]) (:import (clojure.lang ExceptionInfo))) @@ -14,8 +15,8 @@ client (vault.core/new-client vault-url) new-config {:max_versions 5 :cas_require false - :delete_version_after}] - (vault.core/authenticate! client :token {:client-token token-passed-in}) + :delete_version_after "3h25m19s"}] + (vault.core/authenticate! client :token token-passed-in) (testing "Config can be updated with valid call" (with-redefs [clj-http.client/request @@ -31,7 +32,7 @@ (deftest read-config-test (let [config {:max_versions 5 :cas_require false - :delete_version_after} + :delete_version_after "3h25m19s"} mount "mount" token-passed-in "fake-token" vault-url "https://vault.example.amperity.com" @@ -54,6 +55,7 @@ :deletion_time "" :destroyed false :version 1}}} + mount "mount" path-passed-in "path/passed/in" token-passed-in "fake-token" vault-url "https://vault.example.amperity.com" @@ -64,20 +66,26 @@ [clj-http.client/request (fn [req] (is (= :get (:method req))) - (is (= (str vault-url "/v1/data/" path-passed-in) (:url req))) + (is (= (str vault-url "/v1/" mount "/data/" path-passed-in) (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) {:body lookup-response-valid-path})] - (is (= {:foo "bar"} (vault-kv/read client vault-url))))) + (is (= {:foo "bar"} (vault-kv/read-secret client mount path-passed-in))))) (testing "Read responds correctly if no secret is found" (with-redefs [clj-http.client/request (fn [req] (is (= :get (:method req))) - (is (= (str vault-url "/v1/data/" path-passed-in) (:url req))) + (is (= (str vault-url "/v1/" mount "/data/different/path") (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) - {:errors []})] + (ex-info "not found" {:errors [] :status 404 :type ::api-error}))] (try - (vault-kv/read client vault-url) + (is (= {:default-val :is-here} + (vault-kv/read-secret + client + mount + "different/path" + {:not-found {:default-val :is-here}}))) + (vault-kv/read-secret client mount "different/path") (is false) (catch ExceptionInfo e (is (= {:status 404} (ex-data e))))))))) @@ -91,6 +99,7 @@ write-data {:foo "bar" :zip "zap"} options {:cas 0} + mount "mount" path-passed-in "path/passed/in" token-passed-in "fake-token" vault-url "https://vault.example.amperity.com" @@ -101,24 +110,23 @@ [clj-http.client/request (fn [req] (is (= :post (:method req))) - (is (= (str vault-url "/v1/data/" path-passed-in) (:url req))) + (is (= (str vault-url "/v1/" mount "/data/" path-passed-in) (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) - (is (= {:data write-data - :options options} + (is (= {:data write-data} (:data req))) {:body create-success :status 200})] - (is (true? (vault-kv/write! client vault-url write-data options))))) + (is (true? (vault-kv/write-secret! client mount path-passed-in write-data))))) (testing "Write returns false upon failure" (with-redefs [clj-http.client/request (fn [req] (is (= :post (:method req))) - (is (= (str vault-url "/v1/data/" path-passed-in) (:url req))) + (is (= (str vault-url "/v1/" mount "/data/" path-passed-in) (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) (is (= {:data write-data :options options} (:data req))) {:errors [] :status 404})] - (is (false? (vault-kv/write! client vault-url write-data options))))))) + (is (false? (vault-kv/write-secret! client mount path-passed-in write-data))))))) From b0d823d61bf9738849cf8ceb320627a928f4028e Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Wed, 13 Nov 2019 11:15:33 -0800 Subject: [PATCH 12/53] Fixed write-config test --- src/vault/secrets/kvv2.clj | 2 +- test/vault/secrets/kvv2_test.clj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vault/secrets/kvv2.clj b/src/vault/secrets/kvv2.clj index afe2ba7..81f328b 100644 --- a/src/vault/secrets/kvv2.clj +++ b/src/vault/secrets/kvv2.clj @@ -45,7 +45,7 @@ (log/debug "Wrote config" path) (case (int (:status response -1)) 204 true - 200 (:body response) + 200 (or (:body response) true) false))) (defn read-config diff --git a/test/vault/secrets/kvv2_test.clj b/test/vault/secrets/kvv2_test.clj index bf0d47f..7b42442 100644 --- a/test/vault/secrets/kvv2_test.clj +++ b/test/vault/secrets/kvv2_test.clj @@ -24,7 +24,7 @@ (is (= :post (:method req))) (is (= (str vault-url "/v1/" mount "/config") (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) - (is (= new-config (:data req))) + (is (= new-config (:form-params req))) {:status 200})] (is (true? (vault-kv/write-config! client mount new-config))))))) From 60630c62f419806af74e9c06de87c37de8a2de9f Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Wed, 13 Nov 2019 11:30:14 -0800 Subject: [PATCH 13/53] Fixed bug in kvv2 and logical read where api errors were handled incorrectly --- src/vault/secrets/kvv2.clj | 4 ++-- src/vault/secrets/logical.clj | 2 +- test/vault/secrets/kvv2_test.clj | 9 ++++++--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/vault/secrets/kvv2.clj b/src/vault/secrets/kvv2.clj index 81f328b..975c979 100644 --- a/src/vault/secrets/kvv2.clj +++ b/src/vault/secrets/kvv2.clj @@ -21,9 +21,9 @@ (catch ExceptionInfo ex (if (and (contains? opts :not-found) - (= ::api-error (:type (ex-data ex))) + (= ::http-client/api-error (:type (ex-data ex))) (= 404 (:status (ex-data ex)))) - ;(:not-found opts) + (:not-found opts) (throw ex))))) (defn write-secret! diff --git a/src/vault/secrets/logical.clj b/src/vault/secrets/logical.clj index f72bb94..233bb03 100644 --- a/src/vault/secrets/logical.clj +++ b/src/vault/secrets/logical.clj @@ -50,7 +50,7 @@ (:data info)) (catch ExceptionInfo ex (if (and (contains? opts :not-found) - (= ::api-error (:type (ex-data ex))) + (= ::http-client/api-error (:type (ex-data ex))) (= 404 (:status (ex-data ex)))) (:not-found opts) (throw ex)))))) diff --git a/test/vault/secrets/kvv2_test.clj b/test/vault/secrets/kvv2_test.clj index 7b42442..54820e0 100644 --- a/test/vault/secrets/kvv2_test.clj +++ b/test/vault/secrets/kvv2_test.clj @@ -1,7 +1,7 @@ (ns vault.secrets.kvv2-test (:require [clojure.test :refer [testing deftest is]] - [vault.client.http] + [vault.client.http :as http-client] [vault.secrets.kvv2 :as vault-kv]) (:import (clojure.lang @@ -77,7 +77,7 @@ (is (= :get (:method req))) (is (= (str vault-url "/v1/" mount "/data/different/path") (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) - (ex-info "not found" {:errors [] :status 404 :type ::api-error}))] + (throw (ex-info "not found" {:errors [] :status 404 :type ::api-error})))] (try (is (= {:default-val :is-here} (vault-kv/read-secret @@ -88,7 +88,10 @@ (vault-kv/read-secret client mount "different/path") (is false) (catch ExceptionInfo e - (is (= {:status 404} (ex-data e))))))))) + (is (= {:errors nil + :status 404 + :type ::http-client/api-error} + (ex-data e))))))))) (deftest write!-test From d03a940eb9da9c1711d23d3deb2ae1e20a27eaf4 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Wed, 13 Nov 2019 11:52:32 -0800 Subject: [PATCH 14/53] Fixed write test, and write --- src/vault/secrets/kvv2.clj | 16 ++++++++++++---- test/vault/secrets/kvv2_test.clj | 14 ++++++-------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/vault/secrets/kvv2.clj b/src/vault/secrets/kvv2.clj index 975c979..903ba65 100644 --- a/src/vault/secrets/kvv2.clj +++ b/src/vault/secrets/kvv2.clj @@ -1,19 +1,19 @@ (ns vault.secrets.kvv2 (:require [vault.secret-engines :as engine] [vault.client.http :as http-client] - [vault.lease :as lease] [clojure.tools.logging :as log] - [clojure.string :as str] - [vault.secrets.dispatch :refer [read-secret* write-config!* read-config*]] + [vault.secrets.dispatch :refer [read-secret* write-secret!* write-config!* read-config*]] [vault.secrets.logical :as vault-logical]) (:import (clojure.lang ExceptionInfo))) + (defn read-secret ([client mount path opts] (engine/read-secret client (str mount "/data/" path) opts :kvv2)) ([client mount path] (read-secret client mount path nil))) + (defmethod read-secret* :kvv2 [client path opts _] (try @@ -26,9 +26,15 @@ (:not-found opts) (throw ex))))) + (defn write-secret! [client mount path data] - (engine/write-secret! client (str mount "/data/" path) data :logical)) + (engine/write-secret! client (str mount "/data/" path) data :kvv2)) + + +(defmethod write-secret!* :kvv2 + [client path data _] + (or (:data (engine/write-secret! client path {:data data} :logical)) false)) (defn write-config! @@ -48,10 +54,12 @@ 200 (or (:body response) true) false))) + (defn read-config [client mount] (-> (engine/read-config client (str mount "/config") :kvv2) :body :data)) + (defmethod read-config* :kvv2 [client path _] (http-client/api-request client :get path {})) diff --git a/test/vault/secrets/kvv2_test.clj b/test/vault/secrets/kvv2_test.clj index 54820e0..8b837b5 100644 --- a/test/vault/secrets/kvv2_test.clj +++ b/test/vault/secrets/kvv2_test.clj @@ -101,7 +101,6 @@ :version 1}} write-data {:foo "bar" :zip "zap"} - options {:cas 0} mount "mount" path-passed-in "path/passed/in" token-passed-in "fake-token" @@ -116,20 +115,19 @@ (is (= (str vault-url "/v1/" mount "/data/" path-passed-in) (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) (is (= {:data write-data} - (:data req))) + (:form-params req))) {:body create-success :status 200})] - (is (true? (vault-kv/write-secret! client mount path-passed-in write-data))))) + (is (= (:data create-success) (vault-kv/write-secret! client mount path-passed-in write-data)) ))) (testing "Write returns false upon failure" (with-redefs [clj-http.client/request (fn [req] (is (= :post (:method req))) - (is (= (str vault-url "/v1/" mount "/data/" path-passed-in) (:url req))) + (is (= (str vault-url "/v1/" mount "/data/other-path") (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) - (is (= {:data write-data - :options options} - (:data req))) + (is (= {:data write-data} + (:form-params req))) {:errors [] :status 404})] - (is (false? (vault-kv/write-secret! client mount path-passed-in write-data))))))) + (is (false? (vault-kv/write-secret! client mount "other-path" write-data))))))) From d373794059e28d008a50fef7f3cca91185e0033c Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Wed, 13 Nov 2019 11:53:21 -0800 Subject: [PATCH 15/53] cljstyle --- src/vault/secret_engines.clj | 6 +++--- src/vault/secrets/kvv2.clj | 15 +++++++++------ test/vault/secrets/kvv2_test.clj | 2 +- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/vault/secret_engines.clj b/src/vault/secret_engines.clj index fe3d732..47c7478 100644 --- a/src/vault/secret_engines.clj +++ b/src/vault/secret_engines.clj @@ -51,7 +51,7 @@ - `delete_versions_after` - String that pecifies the length of time before a version is deleted. Accepts Go duration format string.") -(read-config - [client path eng] - "Reads configurations at the given path")) + (read-config + [client path eng] + "Reads configurations at the given path")) diff --git a/src/vault/secrets/kvv2.clj b/src/vault/secrets/kvv2.clj index 903ba65..f753c8c 100644 --- a/src/vault/secrets/kvv2.clj +++ b/src/vault/secrets/kvv2.clj @@ -1,10 +1,13 @@ (ns vault.secrets.kvv2 - (:require [vault.secret-engines :as engine] - [vault.client.http :as http-client] - [clojure.tools.logging :as log] - [vault.secrets.dispatch :refer [read-secret* write-secret!* write-config!* read-config*]] - [vault.secrets.logical :as vault-logical]) - (:import (clojure.lang ExceptionInfo))) + (:require + [clojure.tools.logging :as log] + [vault.client.http :as http-client] + [vault.secret-engines :as engine] + [vault.secrets.dispatch :refer [read-secret* write-secret!* write-config!* read-config*]] + [vault.secrets.logical :as vault-logical]) + (:import + (clojure.lang + ExceptionInfo))) (defn read-secret diff --git a/test/vault/secrets/kvv2_test.clj b/test/vault/secrets/kvv2_test.clj index 8b837b5..40e981c 100644 --- a/test/vault/secrets/kvv2_test.clj +++ b/test/vault/secrets/kvv2_test.clj @@ -118,7 +118,7 @@ (:form-params req))) {:body create-success :status 200})] - (is (= (:data create-success) (vault-kv/write-secret! client mount path-passed-in write-data)) ))) + (is (= (:data create-success) (vault-kv/write-secret! client mount path-passed-in write-data))))) (testing "Write returns false upon failure" (with-redefs [clj-http.client/request From 6bd7612c88197aa6ce7770a66a64030c25f82c41 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Fri, 15 Nov 2019 12:23:58 -0800 Subject: [PATCH 16/53] Modified to have secret engines call through logical ops * And we have gone full circle :) --- src/vault/client/http.clj | 70 +++++++++++++++++++++--------- src/vault/client/mock.clj | 15 +++---- src/vault/core.clj | 38 +++++++++++++++++ src/vault/secret_engines.clj | 57 ------------------------- src/vault/secrets/dispatch.clj | 63 --------------------------- src/vault/secrets/kvv2.clj | 62 ++++++++------------------- src/vault/secrets/logical.clj | 73 +++----------------------------- test/vault/secrets/kvv2_test.clj | 2 +- 8 files changed, 118 insertions(+), 262 deletions(-) delete mode 100644 src/vault/secret_engines.clj delete mode 100644 src/vault/secrets/dispatch.clj diff --git a/src/vault/client/http.clj b/src/vault/client/http.clj index 6975737..6234be9 100644 --- a/src/vault/client/http.clj +++ b/src/vault/client/http.clj @@ -8,10 +8,10 @@ [com.stuartsierra.component :as component] [vault.core :as vault] [vault.lease :as lease] - [vault.secret-engines :as engines] - [vault.secrets.dispatch :as engine-dispatch] [vault.timer :as timer]) (:import + (clojure.lang + ExceptionInfo) java.security.MessageDigest (org.apache.commons.codec.binary Hex))) @@ -525,32 +525,64 @@ this) - engines/SecretEngine - ;; This dispatch allows mocking because all requests go through the client + vault/SecretEngine + (list-secrets - [this path eng] - (engine-dispatch/list-secrets* this path eng)) + [this path] + (let [response (api-request this :get path {:query-params {:list true}}) + data (get-in response [:body :data :keys])] + (log/debugf "List %s (%d results)" path (count data)) + data)) + (read-secret - [this path opts eng] - (engine-dispatch/read-secret* this path opts eng)) + [this path opts] + (or (when-let [lease (and (not (:force-read opts)) + (lease/lookup leases path))] + (when-not (lease/expired? lease) + (:data lease))) + (try + (let [response (api-request this :get path {}) + info (assoc (clean-body response) + :path path + :renew (:renew opts) + :rotate (:rotate opts))] + + (log/debugf "Read %s (valid for %d seconds)" + path (:lease-duration info)) + (lease/update! leases info) + + (:data info)) + (catch ExceptionInfo ex + (if (and (contains? opts :not-found) + (= ::api-error (:type (ex-data ex))) + (= 404 (:status (ex-data ex)))) + (:not-found opts) + (throw ex)))))) + (write-secret! - [this path data eng] - (engine-dispatch/write-secret!* this path data eng)) + [this path data] + (let [response (api-request + this :post path + {:form-params data + :content-type :json})] + (log/debug "Vault client wrote to" path) + (lease/remove-path! leases path) + (case (int (:status response -1)) + 204 true + 200 (:body response) + false))) - (delete-secret! - [this path eng] - (engine-dispatch/delete-secret!* this path eng)) - (write-config! - [this path data eng] - (engine-dispatch/write-config!* this path data eng)) + (delete-secret! + [this path] + (let [response (api-request this :delete path {})] + (log/debug "Vault client deleted resources at" path) + (lease/remove-path! leases path) + (= 204 (:status response)))) - (read-config - [this path eng] - (engine-dispatch/read-config* this path eng)) vault/WrappingClient diff --git a/src/vault/client/mock.clj b/src/vault/client/mock.clj index 571a64a..a08feb7 100644 --- a/src/vault/client/mock.clj +++ b/src/vault/client/mock.clj @@ -3,12 +3,9 @@ [clojure.edn :as edn] [clojure.java.io :as io] [clojure.string :as str] - [clojure.tools.logging :as log] - [vault.core :as vault] - [vault.secret-engines :as engines]) + [vault.core :as vault]) (:import java.net.URI - java.text.SimpleDateFormat (java.util Date UUID))) @@ -153,15 +150,15 @@ this) - engines/SecretEngine + vault/SecretEngine (list-secrets - [this path eng] + [this path] (filter #(str/starts-with? % (str path)) (keys @memory))) (read-secret - [this path opts eng] + [this path opts] (or (get @memory path) (if (contains? opts :not-found) (:not-found opts) @@ -170,13 +167,13 @@ (write-secret! - [this path data eng] + [this path data] (swap! memory assoc path data) true) (delete-secret! - [this path eng] + [this path] (swap! memory dissoc path) true) diff --git a/src/vault/core.clj b/src/vault/core.clj index b311b03..b8af799 100644 --- a/src/vault/core.clj +++ b/src/vault/core.clj @@ -131,6 +131,44 @@ "Removes the watch function registered with the given key, if any.")) +(defprotocol SecretEngine + "Basic API for listing, reading, and writing secrets. + + `eng` is a keyword representing the secret engine/mount" + + (list-secrets + [client path] + "List the secrets located under a path.") + + (read-secret + [client path opts] + "Reads a secret from a path. Returns the full map of stored secret data if + the secret exists, or throws an exception if not. + + Additional options may include: + + - `:not-found` + If the requested path is not found, return this value instead of throwing + an exception. + - `:renew` + Whether or not to renew this secret when the lease is near expiry. + - `:rotate` + Whether or not to rotate this secret when the lease is near expiry and + cannot be renewed. + - `:force-read` + Force the secret to be read from the server even if there is a valid lease cached.") + + (write-secret! + [client path data] + "Writes secret data to a path. `data` should be a map. Returns a + boolean indicating whether the write was successful.") + + (delete-secret! + [client path] + "Removes secret data from a path. Returns a boolean indicating whether the + deletion was successful.")) + + (defprotocol WrappingClient "Secret wrapping API for exchanging limited-use tokens for wrapped data." diff --git a/src/vault/secret_engines.clj b/src/vault/secret_engines.clj deleted file mode 100644 index 47c7478..0000000 --- a/src/vault/secret_engines.clj +++ /dev/null @@ -1,57 +0,0 @@ -(ns vault.secret-engines) - - -(defprotocol SecretEngine - "Basic API for listing, reading, and writing secrets. - - `eng` is a keyword representing the secret engine/mount" - - (list-secrets - [client path eng] - "List the secrets located under a path.") - - (read-secret - [client path opts eng] - "Reads a secret from a path. Returns the full map of stored secret data if - the secret exists, or throws an exception if not. - - Additional options may include: - - - `:not-found` - If the requested path is not found, return this value instead of throwing - an exception. - - `:renew` - Whether or not to renew this secret when the lease is near expiry. - - `:rotate` - Whether or not to rotate this secret when the lease is near expiry and - cannot be renewed. - - `:force-read` - Force the secret to be read from the server even if there is a valid lease cached.") - - (write-secret! - [client path data eng] - "Writes secret data to a path. `data` should be a map. Returns a - boolean indicating whether the write was successful.") - - (delete-secret! - [client path eng] - "Removes secret data from a path. Returns a boolean indicating whether the - deletion was successful.") - - (write-config! - [client path data eng] - "Writes configurations at the given path - - Data is the body of a request specifying: - - `max_versions` – The number (as an int) of versions to keep per key. - This value applies to all keys, but a key's metadata setting can overwrite this value. - Once a key has more than the configured allowed versions the oldest version will - be permanently deleted. Defaults to 10. - - `can-required` - If true all keys will require the cas parameter to be set on all write requests. - - `delete_versions_after` - String that pecifies the length of time before a version is deleted. - Accepts Go duration format string.") - - (read-config - [client path eng] - "Reads configurations at the given path")) - diff --git a/src/vault/secrets/dispatch.clj b/src/vault/secrets/dispatch.clj deleted file mode 100644 index a5a4e43..0000000 --- a/src/vault/secrets/dispatch.clj +++ /dev/null @@ -1,63 +0,0 @@ -; Handles dispatch related to the functions from the secret engines protocol -(ns vault.secrets.dispatch) - -; -- list --------------------------------------------------------------------- - -(defmulti list-secrets* - (fn dispatch [client path eng] eng)) - - -(defmethod list-secrets* :default - [client path eng] - (throw (ex-info "list not supported by the secret engine" {:path path :engine eng}))) - -; -- read --------------------------------------------------------------------- - -(defmulti read-secret* - (fn dispatch [client path opts eng] eng)) - - -(defmethod read-secret* :default - [client path opts eng] - (throw (ex-info "read not supported by the secret engine" {:path path :engine eng}))) - -; -- write! ------------------------------------------------------------------- - -(defmulti write-secret!* - (fn [client path data eng] eng)) - - -(defmethod write-secret!* :default - [client path data eng] - (throw (ex-info "write! not supported by the secret engine" {:path path :engine eng}))) - -; -- delete! ------------------------------------------------------------------ - -(defmulti delete-secret!* - (fn [client path eng] eng)) - - -(defmethod delete-secret!* :default - [client path eng] - (throw (ex-info "delete! not supported by the secret engine" {:path path :engine eng}))) - - -; -- write-config! ------------------------------------------------------------------ - -(defmulti write-config!* - (fn [client path data eng] eng)) - - -(defmethod write-config!* :default - [client path data eng] - (throw (ex-info "write-config! not supported by the secret engine" {:path path :engine eng}))) - -; -- read-config ------------------------------------------------------------------ - -(defmulti read-config* - (fn [client path eng] eng)) - - -(defmethod read-config* :default - [client path eng] - (throw (ex-info "read-config not supported by the secret engine" {:path path :engine eng}))) diff --git a/src/vault/secrets/kvv2.clj b/src/vault/secrets/kvv2.clj index f753c8c..4465d9c 100644 --- a/src/vault/secrets/kvv2.clj +++ b/src/vault/secrets/kvv2.clj @@ -1,10 +1,7 @@ (ns vault.secrets.kvv2 (:require - [clojure.tools.logging :as log] [vault.client.http :as http-client] - [vault.secret-engines :as engine] - [vault.secrets.dispatch :refer [read-secret* write-secret!* write-config!* read-config*]] - [vault.secrets.logical :as vault-logical]) + [vault.core :as vault]) (:import (clojure.lang ExceptionInfo))) @@ -12,58 +9,33 @@ (defn read-secret ([client mount path opts] - (engine/read-secret client (str mount "/data/" path) opts :kvv2)) + (try + (:data (vault/read-secret client (str mount "/data/" path) (dissoc opts :not-found))) + + (catch ExceptionInfo ex + (if (and (contains? opts :not-found) + (= ::http-client/api-error (:type (ex-data ex))) + (= 404 (:status (ex-data ex)))) + (:not-found opts) + (throw ex))))) ([client mount path] (read-secret client mount path nil))) -(defmethod read-secret* :kvv2 - [client path opts _] - (try - (:data (vault-logical/read-secret client path (dissoc opts :not-found))) - - (catch ExceptionInfo ex - (if (and (contains? opts :not-found) - (= ::http-client/api-error (:type (ex-data ex))) - (= 404 (:status (ex-data ex)))) - (:not-found opts) - (throw ex))))) - - (defn write-secret! [client mount path data] - (engine/write-secret! client (str mount "/data/" path) data :kvv2)) - - -(defmethod write-secret!* :kvv2 - [client path data _] - (or (:data (engine/write-secret! client path {:data data} :logical)) false)) + (let [result (vault/write-secret! client (str mount "/data/" path) {:data data})] + (or (:data result) result))) (defn write-config! [client mount data] - (engine/write-config! client (str mount "/config") data :kvv2)) - - -(defmethod write-config!* :kvv2 - [client path data _] - (let [response (http-client/api-request - client :post path - {:form-params data - :content-type :json})] - (log/debug "Wrote config" path) - (case (int (:status response -1)) - 204 true - 200 (or (:body response) true) - false))) + (vault/write-secret! client (str mount "/config") data)) (defn read-config - [client mount] - (-> (engine/read-config client (str mount "/config") :kvv2) :body :data)) - - -(defmethod read-config* :kvv2 - [client path _] - (http-client/api-request client :get path {})) + ([client mount opts] + (vault/read-secret client (str mount "/config") opts)) + ([client mount] + (read-config client mount nil))) diff --git a/src/vault/secrets/logical.clj b/src/vault/secrets/logical.clj index 233bb03..7f4663b 100644 --- a/src/vault/secrets/logical.clj +++ b/src/vault/secrets/logical.clj @@ -1,88 +1,25 @@ (ns vault.secrets.logical (:require - [clojure.tools.logging :as log] - [vault.client.http :as http-client] - [vault.lease :as lease] - [vault.secret-engines :as engine] - [vault.secrets.dispatch :refer [list-secrets* read-secret* write-secret!* delete-secret!*]]) - (:import - (clojure.lang - ExceptionInfo))) + [vault.core :as vault])) (defn list-secrets [client path] - (engine/list-secrets client path :logical)) - - -(defmethod list-secrets* :logical - [client path _] - (let [response (http-client/api-request - client :get path - {:query-params {:list true}}) - data (get-in response [:body :data :keys])] - (log/debugf "List %s (%d results)" path (count data)) - data)) + (vault/list-secrets client path)) (defn read-secret ([client path opts] - (engine/read-secret client path opts :logical)) + (vault/read-secret client path opts)) ([client path] (read-secret client path nil))) -(defmethod read-secret* :logical - [client path opts _] - (or (when-let [lease (and (not (:force-read opts)) - (lease/lookup (:leases client) path))] - (when-not (lease/expired? lease) - (:data lease))) - (try - (let [response (http-client/api-request client :get path {}) - info (assoc (http-client/clean-body response) - :path path - :renew (:renew opts) - :rotate (:rotate opts))] - (log/debugf "Read %s (valid for %d seconds)" - path (:lease-duration info)) - (lease/update! (:leases client) info) - (:data info)) - (catch ExceptionInfo ex - (if (and (contains? opts :not-found) - (= ::http-client/api-error (:type (ex-data ex))) - (= 404 (:status (ex-data ex)))) - (:not-found opts) - (throw ex)))))) - - (defn write-secret! [client path data] - (engine/write-secret! client path data :logical)) - - -(defmethod write-secret!* :logical - [client path data _] - (let [response (http-client/api-request - client :post path - {:form-params data - :content-type :json})] - (log/debug "Wrote secret" path) - (lease/remove-path! (:leases client) path) - (case (int (:status response -1)) - 204 true - 200 (:body response) - false))) + (vault/write-secret! client path data)) (defn delete-secret! [client path] - (engine/delete-secret! client path :logical)) - - -(defmethod delete-secret!* :logical - [client path _] - (let [response (http-client/api-request client :delete path {})] - (log/debug "Deleted secret" path) - (lease/remove-path! (:leases client) path) - (= 204 (:status response)))) + (vault/delete-secret! client path)) diff --git a/test/vault/secrets/kvv2_test.clj b/test/vault/secrets/kvv2_test.clj index 40e981c..f62b6b9 100644 --- a/test/vault/secrets/kvv2_test.clj +++ b/test/vault/secrets/kvv2_test.clj @@ -25,7 +25,7 @@ (is (= (str vault-url "/v1/" mount "/config") (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) (is (= new-config (:form-params req))) - {:status 200})] + {:status 204})] (is (true? (vault-kv/write-config! client mount new-config))))))) From 9f52c5842e1fe223a3916a915a9bd0d3b053ce9c Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Mon, 18 Nov 2019 12:06:58 -0800 Subject: [PATCH 17/53] Cleaned up tests --- test/vault/secrets/kvv2_test.clj | 17 +++++++++-------- test/vault/secrets/logical_test.clj | 14 ++++++++------ 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/test/vault/secrets/kvv2_test.clj b/test/vault/secrets/kvv2_test.clj index f62b6b9..c88514a 100644 --- a/test/vault/secrets/kvv2_test.clj +++ b/test/vault/secrets/kvv2_test.clj @@ -2,6 +2,7 @@ (:require [clojure.test :refer [testing deftest is]] [vault.client.http :as http-client] + [vault.core :as vault] [vault.secrets.kvv2 :as vault-kv]) (:import (clojure.lang @@ -12,11 +13,11 @@ (let [mount "mount" token-passed-in "fake-token" vault-url "https://vault.example.amperity.com" - client (vault.core/new-client vault-url) + client (http-client/http-client vault-url) new-config {:max_versions 5 :cas_require false :delete_version_after "3h25m19s"}] - (vault.core/authenticate! client :token token-passed-in) + (vault/authenticate! client :token token-passed-in) (testing "Config can be updated with valid call" (with-redefs [clj-http.client/request @@ -36,8 +37,8 @@ mount "mount" token-passed-in "fake-token" vault-url "https://vault.example.amperity.com" - client (vault.core/new-client vault-url)] - (vault.core/authenticate! client :token token-passed-in) + client (http-client/http-client vault-url)] + (vault/authenticate! client :token token-passed-in) (testing "Config can be read with valid call" (with-redefs [clj-http.client/request @@ -59,8 +60,8 @@ path-passed-in "path/passed/in" token-passed-in "fake-token" vault-url "https://vault.example.amperity.com" - client (vault.core/new-client vault-url)] - (vault.core/authenticate! client :token token-passed-in) + client (http-client/http-client vault-url)] + (vault/authenticate! client :token token-passed-in) (testing "Read responds correctly if secret is successfully located" (with-redefs [clj-http.client/request @@ -105,8 +106,8 @@ path-passed-in "path/passed/in" token-passed-in "fake-token" vault-url "https://vault.example.amperity.com" - client (vault.core/new-client vault-url)] - (vault.core/authenticate! client :token token-passed-in) + client (http-client/http-client vault-url)] + (vault/authenticate! client :token token-passed-in) (testing "Write writes and returns true upon success" (with-redefs [clj-http.client/request diff --git a/test/vault/secrets/logical_test.clj b/test/vault/secrets/logical_test.clj index c40c22d..c9ee373 100644 --- a/test/vault/secrets/logical_test.clj +++ b/test/vault/secrets/logical_test.clj @@ -2,6 +2,8 @@ (:require [clojure.test :refer [is testing deftest]] [vault.client.http] + [vault.client.http :as http-client] + [vault.core :as vault] [vault.secrets.logical :as vault-logical]) (:import (clojure.lang @@ -12,13 +14,13 @@ (let [path "path/passed/in" token-passed-in "fake-token" vault-url "https://vault.example.amperity.com" - client (vault.core/new-client vault-url) + client (http-client/http-client vault-url) response {:auth nil :data {:keys ["foo" "foo/"]} :lease_duration 2764800 :lease-id "" :renewable false}] - (vault.core/authenticate! client :token token-passed-in) + (vault/authenticate! client :token token-passed-in) (testing "List secrets works with valid call" (with-redefs [clj-http.client/request @@ -43,8 +45,8 @@ path-passed-in2 "path/passed/in2" token-passed-in "fake-token" vault-url "https://vault.example.amperity.com" - client (vault.core/new-client vault-url)] - (vault.core/authenticate! client :token token-passed-in) + client (http-client/http-client vault-url)] + (vault/authenticate! client :token token-passed-in) (testing "Read responds correctly if secret is successfully located" (with-redefs [clj-http.client/request @@ -80,8 +82,8 @@ path-passed-in "path/passed/in" token-passed-in "fake-token" vault-url "https://vault.example.amperity.com" - client (vault.core/new-client vault-url)] - (vault.core/authenticate! client :token token-passed-in) + client (http-client/http-client vault-url)] + (vault/authenticate! client :token token-passed-in) (testing "Write writes and returns true upon success" (with-redefs [clj-http.client/request From 8479746784dffe709cdb365fe6001a4cc8151478 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Tue, 19 Nov 2019 14:33:13 -0800 Subject: [PATCH 18/53] Renamed logical to kvv1 --- src/vault/env.clj | 4 ++-- src/vault/secrets/{logical.clj => kvv1.clj} | 2 +- test/vault/client/http_test.clj | 6 +++--- test/vault/secrets/logical_test.clj | 12 ++++++------ 4 files changed, 12 insertions(+), 12 deletions(-) rename src/vault/secrets/{logical.clj => kvv1.clj} (94%) diff --git a/src/vault/env.clj b/src/vault/env.clj index c774ed8..97c1ba9 100644 --- a/src/vault/env.clj +++ b/src/vault/env.clj @@ -12,7 +12,7 @@ [vault.client.http] [vault.client.mock] [vault.core :as vault] - [vault.secrets.logical :as vault-logical])) + [vault.secrets.kvv1 :as vault-kvv1])) (def vault-prefix "vault:") @@ -99,7 +99,7 @@ (throw (ex-info "Cannot resolve secret without initialized client" {:uri vault-uri}))) (let [[path attr] (str/split (subs vault-uri (count vault-prefix)) #"#") - secret (vault-logical/read-secret client path) + secret (vault-kvv1/read-secret client path) attr (or (keyword attr) :data) value (get secret attr)] (when (nil? value) diff --git a/src/vault/secrets/logical.clj b/src/vault/secrets/kvv1.clj similarity index 94% rename from src/vault/secrets/logical.clj rename to src/vault/secrets/kvv1.clj index 7f4663b..e4e98ce 100644 --- a/src/vault/secrets/logical.clj +++ b/src/vault/secrets/kvv1.clj @@ -1,4 +1,4 @@ -(ns vault.secrets.logical +(ns vault.secrets.kvv1 (:require [vault.core :as vault])) diff --git a/test/vault/client/http_test.clj b/test/vault/client/http_test.clj index dd8a0e7..6c67d57 100644 --- a/test/vault/client/http_test.clj +++ b/test/vault/client/http_test.clj @@ -3,7 +3,7 @@ [clojure.test :refer :all] [vault.client.http :refer [http-client] :as h] [vault.core :as vault] - [vault.secrets.logical :as vault-logical])) + [vault.secrets.kvv1 :as vault-kvv1])) (def example-url "https://vault.example.com") @@ -20,10 +20,10 @@ (deftest http-read-checks (let [client (http-client example-url)] (is (thrown? IllegalArgumentException - (vault-logical/read-secret client nil)) + (vault-kvv1/read-secret client nil)) "should throw an exception on non-string path") (is (thrown? IllegalStateException - (vault-logical/read-secret client "secret/foo/bar")) + (vault-kvv1/read-secret client "secret/foo/bar")) "should throw an exception on unauthenticated client"))) diff --git a/test/vault/secrets/logical_test.clj b/test/vault/secrets/logical_test.clj index c9ee373..87c35f3 100644 --- a/test/vault/secrets/logical_test.clj +++ b/test/vault/secrets/logical_test.clj @@ -4,7 +4,7 @@ [vault.client.http] [vault.client.http :as http-client] [vault.core :as vault] - [vault.secrets.logical :as vault-logical]) + [vault.secrets.kvv1 :as vault-kvv1]) (:import (clojure.lang ExceptionInfo))) @@ -31,7 +31,7 @@ (is (true? (-> req :query-params :list))) {:body response})] (is (= ["foo" "foo/"] - (vault-logical/list-secrets client path))))))) + (vault-kvv1/list-secrets client path))))))) (deftest read-secret-test @@ -55,7 +55,7 @@ (is (= (str vault-url "/v1/" path-passed-in) (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) {:body lookup-response-valid-path})] - (is (= {:foo "bar" :ttl "1h"} (vault-logical/read-secret client path-passed-in))))) + (is (= {:foo "bar" :ttl "1h"} (vault-kvv1/read-secret client path-passed-in))))) (testing "Read responds correctly if no secret is found" (with-redefs [clj-http.client/request @@ -64,7 +64,7 @@ (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) (throw (ex-info "not found" {:error [] :status 404})))] (try - (vault-logical/read-secret client path-passed-in2) + (vault-kvv1/read-secret client path-passed-in2) (catch ExceptionInfo e (is (= {:errors nil :status 404 @@ -94,7 +94,7 @@ (is (= write-data (:form-params req))) {:body create-success :status 204})] - (is (true? (vault-logical/write-secret! client path-passed-in write-data))))) + (is (true? (vault-kvv1/write-secret! client path-passed-in write-data))))) (testing "Write returns false upon failure" (with-redefs [clj-http.client/request @@ -106,5 +106,5 @@ (:form-params req))) {:errors [] :status 400})] - (is (false? (vault-logical/write-secret! client path-passed-in write-data))))))) + (is (false? (vault-kvv1/write-secret! client path-passed-in write-data))))))) From aa1e436653b13d38d088c7ff196097393a4cc176 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Tue, 19 Nov 2019 14:34:51 -0800 Subject: [PATCH 19/53] Renamed logical test to kvv1 test --- test/vault/secrets/{logical_test.clj => kvv1_test.clj} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename test/vault/secrets/{logical_test.clj => kvv1_test.clj} (97%) diff --git a/test/vault/secrets/logical_test.clj b/test/vault/secrets/kvv1_test.clj similarity index 97% rename from test/vault/secrets/logical_test.clj rename to test/vault/secrets/kvv1_test.clj index 87c35f3..9adb60f 100644 --- a/test/vault/secrets/logical_test.clj +++ b/test/vault/secrets/kvv1_test.clj @@ -1,4 +1,4 @@ -(ns vault.secrets.logical-test +(ns vault.secrets.kvv1-test (:require [clojure.test :refer [is testing deftest]] [vault.client.http] @@ -21,7 +21,7 @@ :lease-id "" :renewable false}] (vault/authenticate! client :token token-passed-in) - (testing "List secrets works with valid call" + (testing "List secrets has correct response and sends correct request" (with-redefs [clj-http.client/request (fn [req] From d2a96fe89b40d1cb55ea27cc19bb227f52b151fd Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Tue, 19 Nov 2019 14:39:43 -0800 Subject: [PATCH 20/53] Updated test descriptions --- test/vault/secrets/kvv1_test.clj | 8 ++++---- test/vault/secrets/kvv2_test.clj | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/test/vault/secrets/kvv1_test.clj b/test/vault/secrets/kvv1_test.clj index 9adb60f..fafa31c 100644 --- a/test/vault/secrets/kvv1_test.clj +++ b/test/vault/secrets/kvv1_test.clj @@ -47,7 +47,7 @@ vault-url "https://vault.example.amperity.com" client (http-client/http-client vault-url)] (vault/authenticate! client :token token-passed-in) - (testing "Read responds correctly if secret is successfully located" + (testing "Read secrets sends correct request and responds correctly if secret is successfully located" (with-redefs [clj-http.client/request (fn [req] @@ -56,7 +56,7 @@ (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) {:body lookup-response-valid-path})] (is (= {:foo "bar" :ttl "1h"} (vault-kvv1/read-secret client path-passed-in))))) - (testing "Read responds correctly if no secret is found" + (testing "Read secrets sends correct request and responds correctly if no secret is found" (with-redefs [clj-http.client/request (fn [req] @@ -84,7 +84,7 @@ vault-url "https://vault.example.amperity.com" client (http-client/http-client vault-url)] (vault/authenticate! client :token token-passed-in) - (testing "Write writes and returns true upon success" + (testing "Write secrets sends correct request and returns true upon success" (with-redefs [clj-http.client/request (fn [req] @@ -95,7 +95,7 @@ {:body create-success :status 204})] (is (true? (vault-kvv1/write-secret! client path-passed-in write-data))))) - (testing "Write returns false upon failure" + (testing "Write secrets sends correct request and returns false upon failure" (with-redefs [clj-http.client/request (fn [req] diff --git a/test/vault/secrets/kvv2_test.clj b/test/vault/secrets/kvv2_test.clj index c88514a..5de78dc 100644 --- a/test/vault/secrets/kvv2_test.clj +++ b/test/vault/secrets/kvv2_test.clj @@ -18,7 +18,7 @@ :cas_require false :delete_version_after "3h25m19s"}] (vault/authenticate! client :token token-passed-in) - (testing "Config can be updated with valid call" + (testing "Write config sends correct request and returns true on valid call" (with-redefs [clj-http.client/request (fn [req] @@ -39,7 +39,7 @@ vault-url "https://vault.example.amperity.com" client (http-client/http-client vault-url)] (vault/authenticate! client :token token-passed-in) - (testing "Config can be read with valid call" + (testing "Read config sends correct request and returns the config with valid call" (with-redefs [clj-http.client/request (fn [req] @@ -62,7 +62,7 @@ vault-url "https://vault.example.amperity.com" client (http-client/http-client vault-url)] (vault/authenticate! client :token token-passed-in) - (testing "Read responds correctly if secret is successfully located" + (testing "Read secrets sends correct request and responds correctly if secret is successfully located" (with-redefs [clj-http.client/request (fn [req] @@ -71,7 +71,7 @@ (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) {:body lookup-response-valid-path})] (is (= {:foo "bar"} (vault-kv/read-secret client mount path-passed-in))))) - (testing "Read responds correctly if no secret is found" + (testing "Read secrets sends correct request and responds correctly if no secret is found" (with-redefs [clj-http.client/request (fn [req] @@ -108,7 +108,7 @@ vault-url "https://vault.example.amperity.com" client (http-client/http-client vault-url)] (vault/authenticate! client :token token-passed-in) - (testing "Write writes and returns true upon success" + (testing "Write secrets sends correct request and returns true upon success" (with-redefs [clj-http.client/request (fn [req] @@ -120,7 +120,7 @@ {:body create-success :status 200})] (is (= (:data create-success) (vault-kv/write-secret! client mount path-passed-in write-data))))) - (testing "Write returns false upon failure" + (testing "Write secrets sends correct request and returns false upon failure" (with-redefs [clj-http.client/request (fn [req] From a91c74a54cf8ada55ff265d42b01ad7f39a2222b Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Tue, 19 Nov 2019 16:56:20 -0800 Subject: [PATCH 21/53] Moved all the helpers outside of HTTP Client --- src/vault/api_util.clj | 129 +++++++++++ src/vault/authenticate.clj | 132 +++++++++++ src/vault/client/http.clj | 370 +++---------------------------- src/vault/client/mock.clj | 1 + src/vault/lease.clj | 53 ++++- src/vault/secrets/kvv2.clj | 4 +- test/vault/client/http_test.clj | 40 ++-- test/vault/secrets/kvv1_test.clj | 2 +- test/vault/secrets/kvv2_test.clj | 6 +- 9 files changed, 378 insertions(+), 359 deletions(-) create mode 100644 src/vault/api_util.clj create mode 100644 src/vault/authenticate.clj diff --git a/src/vault/api_util.clj b/src/vault/api_util.clj new file mode 100644 index 0000000..2c678ee --- /dev/null +++ b/src/vault/api_util.clj @@ -0,0 +1,129 @@ +(ns vault.api-util + (:require + [cheshire.core :as json] + [clj-http.client :as http] + [clojure.string :as str] + [clojure.tools.logging :as log] + [clojure.walk :as walk]) + (:import + (java.security + MessageDigest) + (org.apache.commons.codec.binary + Hex))) + +;; ## API Utilities + +(defn ^:no-doc kebabify-keys + "Rewrites keyword map keys with underscores changed to dashes." + [value] + (let [kebab-kw #(-> % name (str/replace "_" "-") keyword) + xf-entry (juxt (comp kebab-kw key) val)] + (walk/postwalk + (fn xf-maps + [x] + (if (map? x) + (into {} (map xf-entry) x) + x)) + value))) + + +(defn ^:no-doc sha-256 + "Geerate a SHA-2 256 bit digest from a string." + [s] + (let [hasher (MessageDigest/getInstance "SHA-256") + str-bytes (.getBytes (str s) "UTF-8")] + (.update hasher str-bytes) + (Hex/encodeHexString (.digest hasher)))) + + +(defn ^:no-doc clean-body + "Cleans up a response from the Vault API by rewriting some keywords and + dropping extraneous information. Note that this changes the `:data` in the + response to the original result to preserve accuracy." + [response] + (-> + (:body response) + (kebabify-keys) + (assoc :data (:data (:body response))) + (->> (into {} (filter (comp some? val)))))) + + +(defn ^:no-doc api-error + "Inspects an exception and returns a cleaned-up version if the type is well + understood. Otherwise returns the original error." + [ex] + (let [data (ex-data ex) + status (:status data)] + (if (and status (<= 400 status)) + (let [body (try + (json/parse-string (:body data) true) + (catch Exception _ + nil)) + errors (if (:errors body) + (str/join ", " (:errors body)) + (pr-str body))] + (ex-info (str "Vault API errors: " errors) + {:type ::api-error + :status status + :errors (:errors body)} + ex)) + ex))) + + +(defn ^:no-doc do-api-request + "Performs a request against the API, following redirects at most twice. The + `request-url` should be the full API endpoint." + [method request-url req] + (let [redirects (::redirects req 0)] + (when (<= 2 redirects) + (throw (ex-info (str "Aborting Vault API request after " redirects " redirects") + {:method method, :url request-url}))) + (let [resp (try + (http/request (assoc req :method method :url request-url)) + (catch Exception ex + (throw (api-error ex))))] + (if-let [location (and (#{303 307} (:status resp)) + (get-in resp [:headers "Location"]))] + (do (log/debug "Retrying API request redirected to " location) + (recur method location (assoc req ::redirects (inc redirects)))) + resp)))) + + +(defn ^:no-doc api-request + "Helper method to perform an API request with common headers and values. + Currently always uses API version `v1`. The `path` should be relative to the + version root." + [client method path req] + ; Check API path. + (when-not (and (string? path) (not (empty? path))) + (throw (IllegalArgumentException. + (str "API path must be a non-empty string, got: " + (pr-str path))))) + ; Check client authentication. + (when-not (some-> client :auth deref :client-token) + (throw (IllegalStateException. + "Cannot call API path with unauthenticated client."))) + ; Call API with standard arguments. + (do-api-request + method + (str (:api-url client) "/v1/" path) + (merge + (:http-opts client) + {:accept :json + :as :json} + req + {:headers (merge {"X-Vault-Token" (:client-token @(:auth client))} + (:headers req))}))) + + +(defn ^:no-doc unwrap-secret + "Common function to call the token unwrap endpoint." + [client wrap-token] + (do-api-request + :post (str (:api-url client) "/v1/sys/wrapping/unwrap") + (merge + (:http-opts client) + {:headers {"X-Vault-Token" wrap-token} + :content-type :json + :accept :json + :as :json}))) diff --git a/src/vault/authenticate.clj b/src/vault/authenticate.clj new file mode 100644 index 0000000..bac4a19 --- /dev/null +++ b/src/vault/authenticate.clj @@ -0,0 +1,132 @@ +(ns vault.authenticate + "Handles logic relating to the authentication of a Vault client" + (:require + [clojure.string :as str] + [clojure.tools.logging :as log] + [vault.api-util :as api-util] + [vault.lease :as lease])) + + +(defn ^:no-doc api-auth! + "Validate the response from a vault auth call, update auth-ref with additional + tracking state like lease metadata." + [claim auth-ref response] + (let [auth-info (lease/auth-lease (:auth (api-util/clean-body response)))] + (when-not (:client-token auth-info) + (throw (ex-info (str "No client token returned from non-error API response: " + (:status response) " " (:reason-phrase response)) + {:body (:body response)}))) + (log/info "Successfully authenticated to Vault as %s for policies: %s" + claim (str/join ", " (:policies auth-info))) + (reset! auth-ref auth-info))) + + +(defmulti authenticate* + "Authenticate the client with vault using the given auth-type and credentials." + (fn [client auth-type credentials] auth-type)) + + +(defmethod authenticate* :default + [client auth-type _] + (throw (ex-info (str "Unsupported auth-type " (pr-str auth-type)) + {:auth-type auth-type}))) + + +(defmethod authenticate* :token + [client _ token] + (when-not (string? token) + (throw (IllegalArgumentException. "Token credential must be a string"))) + (reset! (:auth client) {:client-token (str/trim token)})) + + +(defmethod authenticate* :wrap-token + [client _ credentials] + (api-auth! + "wrapped token" + (:auth client) + (api-util/unwrap-secret client credentials))) + + +(defmethod authenticate* :userpass + [client _ credentials] + (let [{:keys [username password]} credentials] + (api-auth! + (str "user " username) + (:auth client) + (api-util/do-api-request + :post (str (:api-url client) "/v1/auth/userpass/login/" username) + (merge + (:http-opts client) + {:form-params {:password password} + :content-type :json + :accept :json + :as :json}))))) + + +(defmethod authenticate* :app-id + [client _ credentials] + (let [{:keys [app user]} credentials] + (api-auth! + (str "app-id " app) + (:auth client) + (api-util/do-api-request + :post (str (:api-url client) "/v1/auth/app-id/login") + (merge + (:http-opts client) + {:form-params {:app_id app, :user_id user} + :content-type :json + :accept :json + :as :json}))))) + + +(defmethod authenticate* :app-role + [client _ credentials] + (let [{:keys [role-id secret-id]} credentials] + (api-auth! + (str "role-id sha256:" (api-util/sha-256 role-id)) + (:auth client) + (api-util/do-api-request + :post (str (:api-url client) "/v1/auth/approle/login") + (merge + (:http-opts client) + {:form-params {:role_id role-id, :secret_id secret-id} + :content-type :json + :accept :json + :as :json}))))) + + +(defmethod authenticate* :ldap + [client _ credentials] + (let [{:keys [username password]} credentials] + (api-auth! + (str "LDAP user " username) + (:auth client) + (api-util/do-api-request + :post (str (:api-url client) "/v1/auth/ldap/login/" username) + (merge + (:http-opts client) + {:form-params {:password password} + :content-type :json + :accept :json + :as :json}))))) + + +(defmethod authenticate* :k8s + [client _ credentials] + (let [{:keys [api-path jwt role]} credentials + api-path (or api-path "/v1/auth/kubernetes/login")] + (when-not jwt + (throw (IllegalArgumentException. "Kubernetes auth credentials must include :jwt"))) + (when-not role + (throw (IllegalArgumentException. "Kubernetes auth credentials must include :role"))) + (api-auth! + (str "Kubernetes auth role=" role) + (:auth client) + (api-util/do-api-request + :post (str (:api-url client) api-path) + (merge + (:http-opts client) + {:form-params {:jwt jwt :role role} + :content-type :json + :accept :json + :as :json}))))) diff --git a/src/vault/client/http.clj b/src/vault/client/http.clj index 6234be9..ddb7dfd 100644 --- a/src/vault/client/http.clj +++ b/src/vault/client/http.clj @@ -1,315 +1,17 @@ (ns vault.client.http + "Defines the Vault HTTP client and constructors" (:require - [cheshire.core :as json] - [clj-http.client :as http] [clojure.string :as str] [clojure.tools.logging :as log] - [clojure.walk :as walk] [com.stuartsierra.component :as component] + [vault.api-util :as api-util] + [vault.authenticate :as authenticate] [vault.core :as vault] [vault.lease :as lease] [vault.timer :as timer]) (:import (clojure.lang - ExceptionInfo) - java.security.MessageDigest - (org.apache.commons.codec.binary - Hex))) - - -;; ## API Utilities - -(defn- kebabify-keys - "Rewrites keyword map keys with underscores changed to dashes." - [value] - (let [kebab-kw #(-> % name (str/replace "_" "-") keyword) - xf-entry (juxt (comp kebab-kw key) val)] - (walk/postwalk - (fn xf-maps - [x] - (if (map? x) - (into {} (map xf-entry) x) - x)) - value))) - - -(defn- sha-256 - "Geerate a SHA-2 256 bit digest from a string." - [s] - (let [hasher (MessageDigest/getInstance "SHA-256") - str-bytes (.getBytes (str s) "UTF-8")] - (.update hasher str-bytes) - (Hex/encodeHexString (.digest hasher)))) - - -(defn clean-body - "Cleans up a response from the Vault API by rewriting some keywords and - dropping extraneous information. Note that this changes the `:data` in the - response to the original result to preserve accuracy." - [response] - (-> - (:body response) - (kebabify-keys) - (assoc :data (:data (:body response))) - (->> (into {} (filter (comp some? val)))))) - - -(defn- api-error - "Inspects an exception and returns a cleaned-up version if the type is well - understood. Otherwise returns the original error." - [ex] - (let [data (ex-data ex) - status (:status data)] - (if (and status (<= 400 status)) - (let [body (try - (json/parse-string (:body data) true) - (catch Exception _ - nil)) - errors (if (:errors body) - (str/join ", " (:errors body)) - (pr-str body))] - (ex-info (str "Vault API errors: " errors) - {:type ::api-error - :status status - :errors (:errors body)} - ex)) - ex))) - - -(defn ^:no-doc do-api-request - "Performs a request against the API, following redirects at most twice. The - `request-url` should be the full API endpoint." - [method request-url req] - (let [redirects (::redirects req 0)] - (when (<= 2 redirects) - (throw (ex-info (str "Aborting Vault API request after " redirects " redirects") - {:method method, :url request-url}))) - (let [resp (try - (http/request (assoc req :method method :url request-url)) - (catch Exception ex - (throw (api-error ex))))] - (if-let [location (and (#{303 307} (:status resp)) - (get-in resp [:headers "Location"]))] - (do (log/debug "Retrying API request redirected to " location) - (recur method location (assoc req ::redirects (inc redirects)))) - resp)))) - - -(defn api-request - "Helper method to perform an API request with common headers and values. - Currently always uses API version `v1`. The `path` should be relative to the - version root." - [client method path req] - ; Check API path. - (when-not (and (string? path) (not (empty? path))) - (throw (IllegalArgumentException. - (str "API path must be a non-empty string, got: " - (pr-str path))))) - ; Check client authentication. - (when-not (some-> client :auth deref :client-token) - (throw (IllegalStateException. - "Cannot call API path with unauthenticated client."))) - ; Call API with standard arguments. - (do-api-request - method - (str (:api-url client) "/v1/" path) - (merge - (:http-opts client) - {:accept :json - :as :json} - req - {:headers (merge {"X-Vault-Token" (:client-token @(:auth client))} - (:headers req))}))) - - -(defn- unwrap-secret - "Common function to call the token unwrap endpoint." - [client wrap-token] - (do-api-request - :post (str (:api-url client) "/v1/sys/wrapping/unwrap") - (merge - (:http-opts client) - {:headers {"X-Vault-Token" wrap-token} - :content-type :json - :accept :json - :as :json}))) - - -;; ## Authentication Methods - -(defn ^:no-doc api-auth! - "Validate the response from a vault auth call, update auth-ref with additional - tracking state like lease metadata." - [claim auth-ref response] - (let [auth-info (lease/auth-lease (:auth (clean-body response)))] - (when-not (:client-token auth-info) - (throw (ex-info (str "No client token returned from non-error API response: " - (:status response) " " (:reason-phrase response)) - {:body (:body response)}))) - (log/infof "Successfully authenticated to Vault as %s for policies: %s" - claim (str/join ", " (:policies auth-info))) - (reset! auth-ref auth-info))) - - -(defmulti authenticate* - "Authenticate the client with vault using the given auth-type and credentials." - (fn [client auth-type credentials] auth-type)) - - -(defmethod authenticate* :default - [client auth-type _] - (throw (ex-info (str "Unsupported auth-type " (pr-str auth-type)) - {:auth-type auth-type}))) - - -(defmethod authenticate* :token - [client _ token] - (when-not (string? token) - (throw (IllegalArgumentException. "Token credential must be a string"))) - (reset! (:auth client) {:client-token (str/trim token)})) - - -(defmethod authenticate* :wrap-token - [client _ credentials] - (api-auth! - "wrapped token" - (:auth client) - (unwrap-secret client credentials))) - - -(defmethod authenticate* :userpass - [client _ credentials] - (let [{:keys [username password]} credentials] - (api-auth! - (str "user " username) - (:auth client) - (do-api-request - :post (str (:api-url client) "/v1/auth/userpass/login/" username) - (merge - (:http-opts client) - {:form-params {:password password} - :content-type :json - :accept :json - :as :json}))))) - - -(defmethod authenticate* :app-id - [client _ credentials] - (let [{:keys [app user]} credentials] - (api-auth! - (str "app-id " app) - (:auth client) - (do-api-request - :post (str (:api-url client) "/v1/auth/app-id/login") - (merge - (:http-opts client) - {:form-params {:app_id app, :user_id user} - :content-type :json - :accept :json - :as :json}))))) - - -(defmethod authenticate* :app-role - [client _ credentials] - (let [{:keys [role-id secret-id]} credentials] - (api-auth! - (str "role-id sha256:" (sha-256 role-id)) - (:auth client) - (do-api-request - :post (str (:api-url client) "/v1/auth/approle/login") - (merge - (:http-opts client) - {:form-params {:role_id role-id, :secret_id secret-id} - :content-type :json - :accept :json - :as :json}))))) - - -(defmethod authenticate* :ldap - [client _ credentials] - (let [{:keys [username password]} credentials] - (api-auth! - (str "LDAP user " username) - (:auth client) - (do-api-request - :post (str (:api-url client) "/v1/auth/ldap/login/" username) - (merge - (:http-opts client) - {:form-params {:password password} - :content-type :json - :accept :json - :as :json}))))) - - -(defmethod authenticate* :k8s - [client _ credentials] - (let [{:keys [api-path jwt role]} credentials - api-path (or api-path "/v1/auth/kubernetes/login")] - (when-not jwt - (throw (IllegalArgumentException. "Kubernetes auth credentials must include :jwt"))) - (when-not role - (throw (IllegalArgumentException. "Kubernetes auth credentials must include :role"))) - (api-auth! - (str "Kubernetes auth role=" role) - (:auth client) - (do-api-request - :post (str (:api-url client) api-path) - (merge - (:http-opts client) - {:form-params {:jwt jwt :role role} - :content-type :json - :accept :json - :as :json}))))) - - -;; ## Timer Logic - -(defn- try-renew-lease! - "Attempts to renew the given secret lease. Updates the lease store or catches - and logs any exception." - [client secret] - (try - (vault/renew-lease client (:lease-id secret)) - (catch Exception ex - (log/error ex "Failed to renew secret lease" (:lease-id secret))))) - - -(defn- try-rotate-secret! - "Attempts to rotate the given secret lease. Updates the lease store or catches - and logs any exception." - [client secret] - (try - (log/info "Rotating secret lease" (:lease-id secret)) - (let [response (api-request client :get (:path secret) {}) - info (assoc (clean-body response) :path (:path secret))] - (lease/update! (:leases client) info)) - (catch Exception ex - (log/error ex "Failed to rotate secret" (:lease-id secret))))) - - -(defn- maintain-leases! - [client window] - (log/trace "Checking for renewable leases...") - ; Check auth token for renewal. - (let [auth @(:auth client)] - (when (and (:renewable auth) - (lease/expires-within? auth window)) - (try - (log/info "Renewing Vault client token") - (vault/renew-token client) - (catch Exception ex - (log/error ex "Failed to renew client token!"))))) - ; Renew leases that are within expiry window and are configured for renewal. - ; Rotate secrets that are about to expire and not renewable. - (let [renewable (lease/renewable-leases (:leases client) window) - rotatable (lease/rotatable-leases (:leases client) window)] - (doseq [secret renewable] - (try-renew-lease! client secret)) - ; Rotate leases that are within expiry window and not renewable. - (doseq [secret rotatable] - (try-rotate-secret! client secret))) - ; Drop any expired leases. - (lease/sweep! (:leases client))) + ExceptionInfo))) ;; ## HTTP Client Type @@ -340,7 +42,7 @@ period (:lease-check-period this 60) jitter (:lease-check-jitter this 10) thread (timer/start! "vault-lease-timer" - #(maintain-leases! this window) + #(lease/maintain-leases! this window) period jitter)] (assoc this :lease-timer thread)))) @@ -370,18 +72,18 @@ (authenticate! [this auth-type credentials] - (authenticate* this auth-type credentials) + (authenticate/authenticate* this auth-type credentials) this) (status [this] - (-> (do-api-request + (-> (api-util/do-api-request :get (str api-url "/v1/sys/health") (assoc http-opts :accept :json :as :json)) - (clean-body))) + (api-util/clean-body))) vault/TokenManager @@ -391,40 +93,40 @@ (let [params (->> (dissoc opts :wrap-ttl) (map (fn [[k v]] [(str/replace (name k) "-" "_") v])) (into {})) - response (api-request + response (api-util/api-request this :post "auth/token/create" {:headers (when-let [ttl (:wrap-ttl opts)] {"X-Vault-Wrap-TTL" ttl}) :form-params params :content-type :json})] ; Return auth info if available, or wrap info if not. - (or (-> response :body :auth kebabify-keys) - (-> response :body :wrap_info kebabify-keys) + (or (-> response :body :auth api-util/kebabify-keys) + (-> response :body :wrap_info api-util/kebabify-keys) (throw (ex-info "No auth or wrap-info in response body" {:body (:body response)}))))) (lookup-token [this] - (-> (api-request this :get "auth/token/lookup-self" {}) + (-> (api-util/api-request this :get "auth/token/lookup-self" {}) (get-in [:body :data]) - (kebabify-keys))) + (api-util/kebabify-keys))) (lookup-token [this token] - (-> (api-request + (-> (api-util/api-request this :post "auth/token/lookup" {:form-params {:token token} :content-type :json}) (get-in [:body :data]) - (kebabify-keys))) + (api-util/kebabify-keys))) (renew-token [this] - (let [response (api-request this :post "auth/token/renew-self" {}) - auth-info (lease/auth-lease (:auth (clean-body response)))] + (let [response (api-util/api-request this :post "auth/token/renew-self" {}) + auth-info (lease/auth-lease (:auth (api-util/clean-body response)))] (when-not (:client-token auth-info) (throw (ex-info (str "No client token returned from token renewal response: " (:status response) " " (:reason-phrase response)) @@ -435,11 +137,11 @@ (renew-token [this token] - (-> (api-request + (-> (api-util/api-request this :post "auth/token/renew" {:form-params {:token token} :content-type :json}) - (clean-body) + (api-util/clean-body) (:auth))) @@ -451,7 +153,7 @@ (revoke-token! [this token] - (let [response (api-request + (let [response (api-util/api-request this :post "auth/token/revoke" {:form-params {:token token} :content-type :json})] @@ -460,17 +162,17 @@ (lookup-accessor [this token-accessor] - (-> (api-request + (-> (api-util/api-request this :post "auth/token/lookup-accessor" {:form-params {:accessor token-accessor} :content-type :json}) (get-in [:body :data]) - (kebabify-keys))) + (api-util/kebabify-keys))) (revoke-accessor! [this token-accessor] - (let [response (api-request + (let [response (api-util/api-request this :post "auth/token/revoke-accessor" {:form-params {:accessor token-accessor} :content-type :json})] @@ -488,11 +190,11 @@ [this lease-id] (log/debug "Renewing lease" lease-id) (let [current (lease/lookup leases lease-id) - response (api-request + response (api-util/api-request this :put "sys/renew" {:form-params {:lease_id lease-id} :content-type :json}) - info (clean-body response)] + info (api-util/clean-body response)] ; If the lease looks renewable but the lease-duration is shorter than the ; existing lease, we're up against the max-ttl and the lease should not ; be considered renewable. @@ -508,7 +210,7 @@ (revoke-lease! [this lease-id] (log/debug "Revoking lease" lease-id) - (let [response (api-request this :put (str "sys/revoke/" lease-id) {})] + (let [response (api-util/api-request this :put (str "sys/revoke/" lease-id) {})] (lease/remove-lease! leases lease-id) (= 204 (:status response)))) @@ -530,7 +232,7 @@ (list-secrets [this path] - (let [response (api-request this :get path {:query-params {:list true}}) + (let [response (api-util/api-request this :get path {:query-params {:list true}}) data (get-in response [:body :data :keys])] (log/debugf "List %s (%d results)" path (count data)) data)) @@ -543,8 +245,8 @@ (when-not (lease/expired? lease) (:data lease))) (try - (let [response (api-request this :get path {}) - info (assoc (clean-body response) + (let [response (api-util/api-request this :get path {}) + info (assoc (api-util/clean-body response) :path path :renew (:renew opts) :rotate (:rotate opts))] @@ -556,7 +258,7 @@ (:data info)) (catch ExceptionInfo ex (if (and (contains? opts :not-found) - (= ::api-error (:type (ex-data ex))) + (= ::api-util/api-error (:type (ex-data ex))) (= 404 (:status (ex-data ex)))) (:not-found opts) (throw ex)))))) @@ -564,7 +266,7 @@ (write-secret! [this path data] - (let [response (api-request + (let [response (api-util/api-request this :post path {:form-params data :content-type :json})] @@ -578,7 +280,7 @@ (delete-secret! [this path] - (let [response (api-request this :delete path {})] + (let [response (api-util/api-request this :delete path {})] (log/debug "Vault client deleted resources at" path) (lease/remove-path! leases path) (= 204 (:status response)))) @@ -588,19 +290,19 @@ (wrap! [this data ttl] - (-> (api-request + (-> (api-util/api-request this :post "sys/wrapping/wrap" {:headers {"X-Vault-Wrap-TTL" ttl} :form-params data :content-type :json}) (get-in [:body :wrap_info]) - (kebabify-keys))) + (api-util/kebabify-keys))) (unwrap! [this wrap-token] - (let [response (unwrap-secret this wrap-token)] - (or (-> response :body :auth kebabify-keys) + (let [response (api-util/unwrap-secret this wrap-token)] + (or (-> response :body :auth api-util/kebabify-keys) (-> response :body :data) (throw (ex-info "No auth info or data in response body" {:body (:body response)})))))) diff --git a/src/vault/client/mock.clj b/src/vault/client/mock.clj index a08feb7..dee67f5 100644 --- a/src/vault/client/mock.clj +++ b/src/vault/client/mock.clj @@ -1,4 +1,5 @@ (ns vault.client.mock + "Defines the mock Vault client" (:require [clojure.edn :as edn] [clojure.java.io :as io] diff --git a/src/vault/lease.clj b/src/vault/lease.clj index ab0f6fb..6a54ed2 100644 --- a/src/vault/lease.clj +++ b/src/vault/lease.clj @@ -2,7 +2,9 @@ "Storage logic for Vault secrets and their associated leases." (:require [clojure.string :as str] - [clojure.tools.logging :as log]) + [clojure.tools.logging :as log] + [vault.api-util :as api-util] + [vault.core :as vault]) (:import java.time.Instant)) @@ -166,3 +168,52 @@ (when (not= (:lease-id old-info) (:lease-id new-info)) (watch-fn new-info))))) + +;; Larger scale lease logic + +(defn ^:no-doc try-renew-lease! + "Attempts to renew the given secret lease. Updates the lease store or catches + and logs any exception." + [client secret] + (try + (vault/renew-lease client (:lease-id secret)) + (catch Exception ex + (log/error ex "Failed to renew secret lease" (:lease-id secret))))) + + +(defn ^:no-doc try-rotate-secret! + "Attempts to rotate the given secret lease. Updates the lease store or catches + and logs any exception." + [client secret] + (try + (log/info "Rotating secret lease" (:lease-id secret)) + (let [response (api-util/api-request client :get (:path secret) {}) + info (assoc (api-util/clean-body response) :path (:path secret))] + (update! (:leases client) info)) + (catch Exception ex + (log/error ex "Failed to rotate secret" (:lease-id secret))))) + + +(defn ^:no-doc maintain-leases! + [client window] + (log/trace "Checking for renewable leases...") + ; Check auth token for renewal. + (let [auth @(:auth client)] + (when (and (:renewable auth) + (expires-within? auth window)) + (try + (log/info "Renewing Vault client token") + (vault/renew-token client) + (catch Exception ex + (log/error ex "Failed to renew client token!"))))) + ; Renew leases that are within expiry window and are configured for renewal. + ; Rotate secrets that are about to expire and not renewable. + (let [renewable (renewable-leases (:leases client) window) + rotatable (rotatable-leases (:leases client) window)] + (doseq [secret renewable] + (try-renew-lease! client secret)) + ; Rotate leases that are within expiry window and not renewable. + (doseq [secret rotatable] + (try-rotate-secret! client secret))) + ; Drop any expired leases. + (sweep! (:leases client))) diff --git a/src/vault/secrets/kvv2.clj b/src/vault/secrets/kvv2.clj index 4465d9c..60cd824 100644 --- a/src/vault/secrets/kvv2.clj +++ b/src/vault/secrets/kvv2.clj @@ -1,6 +1,6 @@ (ns vault.secrets.kvv2 (:require - [vault.client.http :as http-client] + [vault.api-util :as api-util] [vault.core :as vault]) (:import (clojure.lang @@ -14,7 +14,7 @@ (catch ExceptionInfo ex (if (and (contains? opts :not-found) - (= ::http-client/api-error (:type (ex-data ex))) + (= ::api-util/api-error (:type (ex-data ex))) (= 404 (:status (ex-data ex)))) (:not-found opts) (throw ex))))) diff --git a/test/vault/client/http_test.clj b/test/vault/client/http_test.clj index 6c67d57..d09cf73 100644 --- a/test/vault/client/http_test.clj +++ b/test/vault/client/http_test.clj @@ -1,7 +1,9 @@ (ns vault.client.http-test (:require [clojure.test :refer :all] - [vault.client.http :refer [http-client] :as h] + [vault.api-util :as api-util] + [vault.authenticate :as authenticate] + [vault.client.http :refer [http-client]] [vault.core :as vault] [vault.secrets.kvv1 :as vault-kvv1])) @@ -20,10 +22,10 @@ (deftest http-read-checks (let [client (http-client example-url)] (is (thrown? IllegalArgumentException - (vault-kvv1/read-secret client nil)) + (vault-kvv1/read-secret client nil)) "should throw an exception on non-string path") (is (thrown? IllegalStateException - (vault-kvv1/read-secret client "secret/foo/bar")) + (vault-kvv1/read-secret client "secret/foo/bar")) "should throw an exception on unauthenticated client"))) @@ -31,10 +33,10 @@ (let [api-endpoint (str example-url "/v1/auth/approle/login") client (http-client example-url) connection-attempt (atom nil)] - (with-redefs [h/do-api-request + (with-redefs [api-util/do-api-request (fn [method url req] (reset! connection-attempt url)) - h/api-auth! + authenticate/api-auth! (fn [claim auth-ref response] nil)] (vault/authenticate! client :app-role {:secret-id "secret" :role-id "role-id"}) @@ -47,12 +49,12 @@ (let [client (http-client example-url) api-requests (atom []) api-auths (atom [])] - (with-redefs [h/do-api-request (fn [& args] - (swap! api-requests conj args) - :do-api-request-response) - h/api-auth! (fn [& args] - (swap! api-auths conj args) - :api-auth!-response)] + (with-redefs [api-util/do-api-request (fn [& args] + (swap! api-requests conj args) + :do-api-request-response) + authenticate/api-auth! (fn [& args] + (swap! api-auths conj args) + :api-auth!-response)] (vault/authenticate! client :k8s {:jwt "fake-jwt-goes-here" :role "my-role"}) (is (= [[:post @@ -70,10 +72,10 @@ (let [client (http-client example-url) api-requests (atom []) api-auths (atom [])] - (with-redefs [h/do-api-request (fn [& args] - (swap! api-requests conj args)) - h/api-auth! (fn [& args] - (swap! api-auths conj args))] + (with-redefs [api-util/do-api-request (fn [& args] + (swap! api-requests conj args)) + authenticate/api-auth! (fn [& args] + (swap! api-auths conj args))] (is (thrown? IllegalArgumentException (vault/authenticate! client :k8s {:role "my-role"}))) (is (empty? @api-requests)) @@ -82,10 +84,10 @@ (let [client (http-client example-url) api-requests (atom []) api-auths (atom [])] - (with-redefs [h/do-api-request (fn [& args] - (swap! api-requests conj args)) - h/api-auth! (fn [& args] - (swap! api-auths conj args))] + (with-redefs [api-util/do-api-request (fn [& args] + (swap! api-requests conj args)) + authenticate/api-auth! (fn [& args] + (swap! api-auths conj args))] (is (thrown? IllegalArgumentException (vault/authenticate! client :k8s {:jwt "fake-jwt-goes-here"}))) (is (empty? @api-requests)) diff --git a/test/vault/secrets/kvv1_test.clj b/test/vault/secrets/kvv1_test.clj index fafa31c..a769b2f 100644 --- a/test/vault/secrets/kvv1_test.clj +++ b/test/vault/secrets/kvv1_test.clj @@ -68,7 +68,7 @@ (catch ExceptionInfo e (is (= {:errors nil :status 404 - :type :vault.client.http/api-error} + :type :vault.api-util/api-error} (ex-data e))))))))) diff --git a/test/vault/secrets/kvv2_test.clj b/test/vault/secrets/kvv2_test.clj index 5de78dc..863bd49 100644 --- a/test/vault/secrets/kvv2_test.clj +++ b/test/vault/secrets/kvv2_test.clj @@ -1,6 +1,7 @@ (ns vault.secrets.kvv2-test (:require [clojure.test :refer [testing deftest is]] + [vault.api-util :as api-util] [vault.client.http :as http-client] [vault.core :as vault] [vault.secrets.kvv2 :as vault-kv]) @@ -78,7 +79,7 @@ (is (= :get (:method req))) (is (= (str vault-url "/v1/" mount "/data/different/path") (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) - (throw (ex-info "not found" {:errors [] :status 404 :type ::api-error})))] + (throw (ex-info "not found" {:errors [] :status 404 :type :vault.api-util/api-error})))] (try (is (= {:default-val :is-here} (vault-kv/read-secret @@ -86,12 +87,13 @@ mount "different/path" {:not-found {:default-val :is-here}}))) + (vault-kv/read-secret client mount "different/path") (is false) (catch ExceptionInfo e (is (= {:errors nil :status 404 - :type ::http-client/api-error} + :type ::api-util/api-error} (ex-data e))))))))) From 9adda0d7ecca68203c353d3a8bcc3b1959bc6bba Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Wed, 20 Nov 2019 13:11:37 -0800 Subject: [PATCH 22/53] Bumped the SNAPSHOT version --- project.clj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project.clj b/project.clj index c1d6513..9da4b90 100644 --- a/project.clj +++ b/project.clj @@ -1,4 +1,4 @@ -(defproject amperity/vault-clj "0.7.1-SNAPSHOT" +(defproject amperity/vault-clj "0.7.2-SNAPSHOT" :description "Clojure client for the Vault secret management system." :url "https://github.com/amperity/vault-clj" :license {:name "Apache License" From 90377a71965d11c0d00d008312b60cdfffcef723 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Wed, 20 Nov 2019 16:43:57 -0800 Subject: [PATCH 23/53] Added tests for kvv1 mock read, write, list --- test/vault/client/mock_test.clj | 2 +- test/vault/client/secret-fixture.edn | 2 ++ test/vault/secrets/kvv1_test.clj | 37 +++++++++++++++++++++++++++- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/test/vault/client/mock_test.clj b/test/vault/client/mock_test.clj index bf76994..2817cb3 100644 --- a/test/vault/client/mock_test.clj +++ b/test/vault/client/mock_test.clj @@ -10,7 +10,7 @@ (defn mock-client-authenticated "A mock vault client using the secrets found in `resources/secret-fixture.edn`" [] - (let [client (vault/new-client "mock:amperity/gocd/secret/vault/secret-fixture.edn")] + (let [client (vault/new-client "mock:vault/client/secret-fixture.edn")] (vault/authenticate! client :token "fake-token") client)) diff --git a/test/vault/client/secret-fixture.edn b/test/vault/client/secret-fixture.edn index e69de29..6cab7c2 100644 --- a/test/vault/client/secret-fixture.edn +++ b/test/vault/client/secret-fixture.edn @@ -0,0 +1,2 @@ +{"identities" {:batman "Bruce Wayne" + :captain-marvel "Carol Danvers"}} diff --git a/test/vault/secrets/kvv1_test.clj b/test/vault/secrets/kvv1_test.clj index a769b2f..fff64c6 100644 --- a/test/vault/secrets/kvv1_test.clj +++ b/test/vault/secrets/kvv1_test.clj @@ -1,8 +1,8 @@ (ns vault.secrets.kvv1-test (:require [clojure.test :refer [is testing deftest]] - [vault.client.http] [vault.client.http :as http-client] + [vault.client.mock-test :as mock-test] [vault.core :as vault] [vault.secrets.kvv1 :as vault-kvv1]) (:import @@ -10,6 +10,8 @@ ExceptionInfo))) +;; -------- HTTP Client ------------------------------------------------------- + (deftest list-secrets-test (let [path "path/passed/in" token-passed-in "fake-token" @@ -108,3 +110,36 @@ :status 400})] (is (false? (vault-kvv1/write-secret! client path-passed-in write-data))))))) + +;; TODO: TEST DELETE in mock and HTTP + +;; -------- Mock Client ------------------------------------------------------- + +(deftest mock-client-test + (testing "Mock client can correctly read values it was initialized with" + (is (= {:batman "Bruce Wayne" + :captain-marvel "Carol Danvers"} + (vault-kvv1/read-secret (mock-test/mock-client-authenticated) "identities")))) + (testing "Mock client correctly responds with a 404 to non-existent paths" + (is (thrown-with-msg? ExceptionInfo #"No such secret: hello" + (vault-kvv1/read-secret (mock-test/mock-client-authenticated) "hello"))) + (is (thrown-with-msg? ExceptionInfo #"No such secret: identities" + (vault-kvv1/read-secret (vault/new-client "mock:-") "identities")))) + (testing "Mock client can write/update and read data" + (let [client (mock-test/mock-client-authenticated)] + (is (thrown-with-msg? ExceptionInfo #"No such secret: hello" + (vault-kvv1/read-secret client "hello"))) + (is (true? (vault-kvv1/write-secret! client "hello" {:and-i-say "goodbye"}))) + (is (true? (vault-kvv1/write-secret! client "identities" {:intersect "Chuck"}))) + (is (= {:and-i-say "goodbye"} + (vault-kvv1/read-secret client "hello"))) + (is (= {:intersect "Chuck"} + (vault-kvv1/read-secret client "identities"))))) + (testing "Mock client can list secrets" + (let [client (mock-test/mock-client-authenticated)] + (is (empty? (vault-kvv1/list-secrets client "hello"))) + (is (true? (vault-kvv1/write-secret! client "hello" {:and-i-say "goodbye"}))) + (is (true? (vault-kvv1/write-secret! client "identities" {:intersect "Chuck"}))) + (is (= ["identities" "hello"] (into [] (vault/list-secrets client "")))) + (is (= ["identities"] (into [] (vault/list-secrets client "identities"))))))) + From 50a6aa97a88d6cdd414f68c707ad2d6fd0145ce8 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Wed, 20 Nov 2019 16:44:31 -0800 Subject: [PATCH 24/53] cljstyle --- test/vault/secrets/kvv1_test.clj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/vault/secrets/kvv1_test.clj b/test/vault/secrets/kvv1_test.clj index fff64c6..4007c3d 100644 --- a/test/vault/secrets/kvv1_test.clj +++ b/test/vault/secrets/kvv1_test.clj @@ -122,13 +122,13 @@ (vault-kvv1/read-secret (mock-test/mock-client-authenticated) "identities")))) (testing "Mock client correctly responds with a 404 to non-existent paths" (is (thrown-with-msg? ExceptionInfo #"No such secret: hello" - (vault-kvv1/read-secret (mock-test/mock-client-authenticated) "hello"))) + (vault-kvv1/read-secret (mock-test/mock-client-authenticated) "hello"))) (is (thrown-with-msg? ExceptionInfo #"No such secret: identities" - (vault-kvv1/read-secret (vault/new-client "mock:-") "identities")))) + (vault-kvv1/read-secret (vault/new-client "mock:-") "identities")))) (testing "Mock client can write/update and read data" (let [client (mock-test/mock-client-authenticated)] (is (thrown-with-msg? ExceptionInfo #"No such secret: hello" - (vault-kvv1/read-secret client "hello"))) + (vault-kvv1/read-secret client "hello"))) (is (true? (vault-kvv1/write-secret! client "hello" {:and-i-say "goodbye"}))) (is (true? (vault-kvv1/write-secret! client "identities" {:intersect "Chuck"}))) (is (= {:and-i-say "goodbye"} From 8822f4df9597716227e2e74b38ad353055ac2290 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Thu, 21 Nov 2019 11:20:20 -0800 Subject: [PATCH 25/53] Added tests for deleting secrets in kvv1 --- test/vault/secrets/kvv1_test.clj | 44 +++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/test/vault/secrets/kvv1_test.clj b/test/vault/secrets/kvv1_test.clj index 4007c3d..55cefbf 100644 --- a/test/vault/secrets/kvv1_test.clj +++ b/test/vault/secrets/kvv1_test.clj @@ -74,7 +74,7 @@ (ex-data e))))))))) -(deftest write-test +(deftest write-secret-test (let [create-success {:data {:created_time "2018-03-22T02:24:06.945319214Z" :deletion_time "" :destroyed false @@ -111,7 +111,31 @@ (is (false? (vault-kvv1/write-secret! client path-passed-in write-data))))))) -;; TODO: TEST DELETE in mock and HTTP +(deftest delete-secret-test + (let [path-passed-in "path/passed/in" + token-passed-in "fake-token" + vault-url "https://vault.example.amperity.com" + client (http-client/http-client vault-url)] + (vault/authenticate! client :token "fake-token") + (testing "Delete secret returns correctly upon success, and sends correct request" + (with-redefs + [clj-http.client/request + (fn [req] + (is (= :delete (:method req))) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) + (is (= (str vault-url "/v1/" path-passed-in (:url req)))) + {:status 204})] + (is (true? (vault/delete-secret! client path-passed-in))))) + (testing "Delete secret returns correctly upon failure, and sends correct request" + (with-redefs + [clj-http.client/request + (fn [req] + (is (= :delete (:method req))) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) + (is (= (str vault-url "/v1/" path-passed-in (:url req)))) + {:status 404})] + (is (false? (vault/delete-secret! client path-passed-in))))))) + ;; -------- Mock Client ------------------------------------------------------- @@ -141,5 +165,17 @@ (is (true? (vault-kvv1/write-secret! client "hello" {:and-i-say "goodbye"}))) (is (true? (vault-kvv1/write-secret! client "identities" {:intersect "Chuck"}))) (is (= ["identities" "hello"] (into [] (vault/list-secrets client "")))) - (is (= ["identities"] (into [] (vault/list-secrets client "identities"))))))) - + (is (= ["identities"] (into [] (vault/list-secrets client "identities")))))) + (testing "Mock client can delete secrets" + (let [client (mock-test/mock-client-authenticated)] + (is (true? (vault-kvv1/write-secret! client "hello" {:and-i-say "goodbye"}))) + (is (= {:and-i-say "goodbye"} + (vault-kvv1/read-secret client "hello"))) + (is (= {:batman "Bruce Wayne" + :captain-marvel "Carol Danvers"} + (vault-kvv1/read-secret client "identities"))) + ;; delete them + (is (true? (vault-kvv1/delete-secret! client "hello"))) + (is (true? (vault-kvv1/delete-secret! client "identities"))) + (is (thrown? ExceptionInfo (vault-kvv1/read-secret client "hello"))) + (is (thrown? ExceptionInfo (vault-kvv1/read-secret client "identities")))))) From a53aea542f563552a61b5c13e47f42840f09edee Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Thu, 21 Nov 2019 12:23:06 -0800 Subject: [PATCH 26/53] Added some mock tests for read/write in kvv2 --- test/vault/client/mock_test.clj | 12 +++-- ...fixture.edn => secret-fixture-logical.edn} | 0 test/vault/secrets/kvv1_test.clj | 4 +- test/vault/secrets/kvv2_test.clj | 47 +++++++++++++++---- test/vault/secrets/secret-fixture-kvv2.edn | 3 ++ 5 files changed, 51 insertions(+), 15 deletions(-) rename test/vault/client/{secret-fixture.edn => secret-fixture-logical.edn} (100%) create mode 100644 test/vault/secrets/secret-fixture-kvv2.edn diff --git a/test/vault/client/mock_test.clj b/test/vault/client/mock_test.clj index 2817cb3..d5a3902 100644 --- a/test/vault/client/mock_test.clj +++ b/test/vault/client/mock_test.clj @@ -8,11 +8,13 @@ (defn mock-client-authenticated - "A mock vault client using the secrets found in `resources/secret-fixture.edn`" - [] - (let [client (vault/new-client "mock:vault/client/secret-fixture.edn")] - (vault/authenticate! client :token "fake-token") - client)) + "A mock vault client using the secrets found in the given path, defaults to `vault/client/secret-fixture-logical.edn`" + ([path] + (let [client (vault/new-client (str "mock:" path))] + (vault/authenticate! client :token "fake-token") + client)) + ([] + (mock-client-authenticated "vault/client/secret-fixture-logical.edn"))) (deftest create-token!-test diff --git a/test/vault/client/secret-fixture.edn b/test/vault/client/secret-fixture-logical.edn similarity index 100% rename from test/vault/client/secret-fixture.edn rename to test/vault/client/secret-fixture-logical.edn diff --git a/test/vault/secrets/kvv1_test.clj b/test/vault/secrets/kvv1_test.clj index 55cefbf..4e01b7d 100644 --- a/test/vault/secrets/kvv1_test.clj +++ b/test/vault/secrets/kvv1_test.clj @@ -164,8 +164,8 @@ (is (empty? (vault-kvv1/list-secrets client "hello"))) (is (true? (vault-kvv1/write-secret! client "hello" {:and-i-say "goodbye"}))) (is (true? (vault-kvv1/write-secret! client "identities" {:intersect "Chuck"}))) - (is (= ["identities" "hello"] (into [] (vault/list-secrets client "")))) - (is (= ["identities"] (into [] (vault/list-secrets client "identities")))))) + (is (= ["identities" "hello"] (into [] (vault-kvv1/list-secrets client "")))) + (is (= ["identities"] (into [] (vault-kvv1/list-secrets client "identities")))))) (testing "Mock client can delete secrets" (let [client (mock-test/mock-client-authenticated)] (is (true? (vault-kvv1/write-secret! client "hello" {:and-i-say "goodbye"}))) diff --git a/test/vault/secrets/kvv2_test.clj b/test/vault/secrets/kvv2_test.clj index 863bd49..5fa49b3 100644 --- a/test/vault/secrets/kvv2_test.clj +++ b/test/vault/secrets/kvv2_test.clj @@ -3,8 +3,9 @@ [clojure.test :refer [testing deftest is]] [vault.api-util :as api-util] [vault.client.http :as http-client] + [vault.client.mock-test :as mock-test] [vault.core :as vault] - [vault.secrets.kvv2 :as vault-kv]) + [vault.secrets.kvv2 :as vault-kvv2]) (:import (clojure.lang ExceptionInfo))) @@ -28,7 +29,7 @@ (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) (is (= new-config (:form-params req))) {:status 204})] - (is (true? (vault-kv/write-config! client mount new-config))))))) + (is (true? (vault-kvv2/write-config! client mount new-config))))))) (deftest read-config-test @@ -48,7 +49,7 @@ (is (= (str vault-url "/v1/" mount "/config") (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) {:body {:data config}})] - (is (= config (vault-kv/read-config client mount))))))) + (is (= config (vault-kvv2/read-config client mount))))))) (deftest read-test @@ -71,7 +72,7 @@ (is (= (str vault-url "/v1/" mount "/data/" path-passed-in) (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) {:body lookup-response-valid-path})] - (is (= {:foo "bar"} (vault-kv/read-secret client mount path-passed-in))))) + (is (= {:foo "bar"} (vault-kvv2/read-secret client mount path-passed-in))))) (testing "Read secrets sends correct request and responds correctly if no secret is found" (with-redefs [clj-http.client/request @@ -82,13 +83,13 @@ (throw (ex-info "not found" {:errors [] :status 404 :type :vault.api-util/api-error})))] (try (is (= {:default-val :is-here} - (vault-kv/read-secret + (vault-kvv2/read-secret client mount "different/path" {:not-found {:default-val :is-here}}))) - (vault-kv/read-secret client mount "different/path") + (vault-kvv2/read-secret client mount "different/path") (is false) (catch ExceptionInfo e (is (= {:errors nil @@ -121,7 +122,7 @@ (:form-params req))) {:body create-success :status 200})] - (is (= (:data create-success) (vault-kv/write-secret! client mount path-passed-in write-data))))) + (is (= (:data create-success) (vault-kvv2/write-secret! client mount path-passed-in write-data))))) (testing "Write secrets sends correct request and returns false upon failure" (with-redefs [clj-http.client/request @@ -133,4 +134,34 @@ (:form-params req))) {:errors [] :status 404})] - (is (false? (vault-kv/write-secret! client mount "other-path" write-data))))))) + (is (false? (vault-kvv2/write-secret! client mount "other-path" write-data))))))) + + +;; -------- Mock Client ------------------------------------------------------- + +(defn mock-client-kvv2 + "Creates a mock client with the data in `vault/secrets/secret-fixture-kvv2.edn`" + [] + (mock-test/mock-client-authenticated "vault/secrets/secret-fixture-kvv2.edn")) + + +(deftest mock-client-test + (testing "Mock client can correctly read values it was initialized with" + (is (= {:batman "Bruce Wayne" + :captain-marvel "Carol Danvers"} + (vault-kvv2/read-secret (mock-client-kvv2) "mount" "identities")))) + (testing "Mock client correctly responds with a 404 to reading non-existent paths" + (is (thrown-with-msg? ExceptionInfo #"No such secret: mount/data/hello" + (vault-kvv2/read-secret (mock-client-kvv2) "mount" "hello"))) + (is (thrown-with-msg? ExceptionInfo #"No such secret: mount/data/identities" + (vault-kvv2/read-secret (vault/new-client "mock:-") "mount" "identities")))) + (testing "Mock client can write/update and read data" + (let [client (mock-client-kvv2)] + (is (thrown-with-msg? ExceptionInfo #"No such secret: mount/data/hello" + (vault-kvv2/read-secret client "mount" "hello"))) + (is (true? (vault-kvv2/write-secret! client "mount" "hello" {:and-i-say "goodbye"}))) + (is (true? (vault-kvv2/write-secret! client "mount" "identities" {:intersect "Chuck"}))) + (is (= {:and-i-say "goodbye"} + (vault-kvv2/read-secret client "mount" "hello"))) + (is (= {:intersect "Chuck"} + (vault-kvv2/read-secret client "mount" "identities")))))) diff --git a/test/vault/secrets/secret-fixture-kvv2.edn b/test/vault/secrets/secret-fixture-kvv2.edn new file mode 100644 index 0000000..32347fc --- /dev/null +++ b/test/vault/secrets/secret-fixture-kvv2.edn @@ -0,0 +1,3 @@ +{"mount/data/identities" + {:data {:batman "Bruce Wayne" + :captain-marvel "Carol Danvers"}}} From afe46f48a492978756215c2b88146d9a1b37fb33 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Thu, 21 Nov 2019 14:59:26 -0800 Subject: [PATCH 27/53] Add test for mock reading and writing config --- test/vault/secrets/kvv2_test.clj | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/test/vault/secrets/kvv2_test.clj b/test/vault/secrets/kvv2_test.clj index 5fa49b3..88444a3 100644 --- a/test/vault/secrets/kvv2_test.clj +++ b/test/vault/secrets/kvv2_test.clj @@ -17,7 +17,7 @@ vault-url "https://vault.example.amperity.com" client (http-client/http-client vault-url) new-config {:max_versions 5 - :cas_require false + :cas_required false :delete_version_after "3h25m19s"}] (vault/authenticate! client :token token-passed-in) (testing "Write config sends correct request and returns true on valid call" @@ -34,7 +34,7 @@ (deftest read-config-test (let [config {:max_versions 5 - :cas_require false + :cas_required false :delete_version_after "3h25m19s"} mount "mount" token-passed-in "fake-token" @@ -164,4 +164,13 @@ (is (= {:and-i-say "goodbye"} (vault-kvv2/read-secret client "mount" "hello"))) (is (= {:intersect "Chuck"} - (vault-kvv2/read-secret client "mount" "identities")))))) + (vault-kvv2/read-secret client "mount" "identities"))))) + (testing "Mock client can write and read config" + (let [client (mock-client-kvv2) + config {:max-versions 5 + :cas_required false + :delete_version_after "3h23m19s"}] + (is (thrown? ExceptionInfo + (vault-kvv2/read-config client "mount"))) + (is (true? (vault-kvv2/write-config! client "mount" config))) + (is (= config (vault-kvv2/read-config client "mount")))))) From 4b430699ec854e1a1c2fc7749889961f9b908f73 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Fri, 22 Nov 2019 17:24:44 -0800 Subject: [PATCH 28/53] Added support for kvv2 delete (soft deletes) --- src/vault/client/mock.clj | 5 +-- src/vault/secrets/kvv2.clj | 15 +++++++++ test/vault/secrets/kvv2_test.clj | 58 +++++++++++++++++++++++++++++++- 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/vault/client/mock.clj b/src/vault/client/mock.clj index 759c683..8a62372 100644 --- a/src/vault/client/mock.clj +++ b/src/vault/client/mock.clj @@ -200,8 +200,9 @@ (delete-secret! [this path] - (swap! memory dissoc path) - true) + (let [was-in-memeory (contains? @memory path)] + (swap! memory dissoc path) + was-in-memeory)) vault/WrappingClient diff --git a/src/vault/secrets/kvv2.clj b/src/vault/secrets/kvv2.clj index 60cd824..326fdeb 100644 --- a/src/vault/secrets/kvv2.clj +++ b/src/vault/secrets/kvv2.clj @@ -39,3 +39,18 @@ ([client mount] (read-config client mount nil))) + +(defn delete-secret! + "Performs a soft delete a secret. This marks the versions as deleted and will stop them from being returned from + reads, but the underlying data will not be removed. A delete can be undone using the `undelete` path. + + - `client`: the Vault client you wish to delete a secret in + - `mount`: the Vault secret mount (the part of the path which determines which secret engine is used) + - `path`: the path aligned to the secret you wish to delete + - `versions`: vector of the versions of that secret you wish to delete, defaults to deleting the latest version" + ([client mount path versions] + (if (empty? versions) + (vault/delete-secret! client (str mount "/data/" path)) + (vault/write-secret! client (str mount "/delete/" path) versions))) + ([client mount path] + (delete-secret! client mount path nil))) diff --git a/test/vault/secrets/kvv2_test.clj b/test/vault/secrets/kvv2_test.clj index 88444a3..b702bc7 100644 --- a/test/vault/secrets/kvv2_test.clj +++ b/test/vault/secrets/kvv2_test.clj @@ -137,6 +137,54 @@ (is (false? (vault-kvv2/write-secret! client mount "other-path" write-data))))))) +(deftest delete-test + (let [mount "mount" + path-passed-in "path/passed/in" + token-passed-in "fake-token" + vault-url "https://vault.example.amperity.com" + client (http-client/http-client vault-url)] + (vault/authenticate! client :token token-passed-in) + (testing "delete secrets send correct request and returns true upon success when no versions passed in" + (with-redefs + [clj-http.client/request + (fn [req] + (is (= :delete (:method req))) + (is (= (str vault-url "/v1/" mount "/data/" path-passed-in) (:url req))) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) + {:status 204})] + (is (true? (vault-kvv2/delete-secret! client mount path-passed-in)) + (is (true? (vault-kvv2/delete-secret! client mount path-passed-in [])))) + (testing "delete secrets send correct request and returns false upon failure when no versions passed in" + (with-redefs + [clj-http.client/request + (fn [req] + (is (= :delete (:method req))) + (is (= (str vault-url "/v1/" mount "/data/" path-passed-in) (:url req))) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) + {:status 404})] + (is (false? (vault-kvv2/delete-secret! client mount path-passed-in))))) + (testing "delete secrets send correct request and returns true upon success when multiple versions passed in" + (with-redefs + [clj-http.client/request + (fn [req] + (is (= :post (:method req))) + (is (= (str vault-url "/v1/" mount "/delete/" path-passed-in) (:url req))) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) + (is (= [12 14 147] (:form-params req))) + {:status 204})] + (is (true? (vault-kvv2/delete-secret! client mount path-passed-in [12 14 147]))))) + (testing "delete secrets send correct request and returns false upon failure when multiple versions passed in" + (with-redefs + [clj-http.client/request + (fn [req] + (is (= :post (:method req))) + (is (= (str vault-url "/v1/" mount "/delete/" path-passed-in) (:url req))) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) + (is (= [123] (:form-params req))) + {:status 404})] + (is (false? (vault-kvv2/delete-secret! client mount path-passed-in [123]))))))))) + + ;; -------- Mock Client ------------------------------------------------------- (defn mock-client-kvv2 @@ -173,4 +221,12 @@ (is (thrown? ExceptionInfo (vault-kvv2/read-config client "mount"))) (is (true? (vault-kvv2/write-config! client "mount" config))) - (is (= config (vault-kvv2/read-config client "mount")))))) + (is (= config (vault-kvv2/read-config client "mount"))))) + (testing "Mock client returns true if path is found on delete for secret, false if not when no versions specified" + (let [client (mock-client-kvv2)] + (is (true? (vault-kvv2/delete-secret! client "mount" "identities"))) + (is (false? (vault-kvv2/delete-secret! client "mount" "eggsactly"))))) + (testing "Mock client always returns true on delete for secret when versions specified" + (let [client (mock-client-kvv2)] + (is (true? (vault-kvv2/delete-secret! client "mount" "identities" [1]))) + (is (true? (vault-kvv2/delete-secret! client "mount" "eggsactly" [4 5 6])))))) From 20e5895015bb7e60c720f20cea637b6553da9990 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Fri, 22 Nov 2019 17:34:15 -0800 Subject: [PATCH 29/53] Fixed malformed payload expectation in kvv2 delete --- src/vault/secrets/kvv2.clj | 2 +- test/vault/secrets/kvv2_test.clj | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vault/secrets/kvv2.clj b/src/vault/secrets/kvv2.clj index 326fdeb..2f2273b 100644 --- a/src/vault/secrets/kvv2.clj +++ b/src/vault/secrets/kvv2.clj @@ -51,6 +51,6 @@ ([client mount path versions] (if (empty? versions) (vault/delete-secret! client (str mount "/data/" path)) - (vault/write-secret! client (str mount "/delete/" path) versions))) + (vault/write-secret! client (str mount "/delete/" path) {:versions versions}))) ([client mount path] (delete-secret! client mount path nil))) diff --git a/test/vault/secrets/kvv2_test.clj b/test/vault/secrets/kvv2_test.clj index b702bc7..83278e7 100644 --- a/test/vault/secrets/kvv2_test.clj +++ b/test/vault/secrets/kvv2_test.clj @@ -170,7 +170,7 @@ (is (= :post (:method req))) (is (= (str vault-url "/v1/" mount "/delete/" path-passed-in) (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) - (is (= [12 14 147] (:form-params req))) + (is (= {:versions [12 14 147]} (:form-params req))) {:status 204})] (is (true? (vault-kvv2/delete-secret! client mount path-passed-in [12 14 147]))))) (testing "delete secrets send correct request and returns false upon failure when multiple versions passed in" @@ -180,7 +180,7 @@ (is (= :post (:method req))) (is (= (str vault-url "/v1/" mount "/delete/" path-passed-in) (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) - (is (= [123] (:form-params req))) + (is (= {:versions [123]} (:form-params req))) {:status 404})] (is (false? (vault-kvv2/delete-secret! client mount path-passed-in [123]))))))))) From 47198fba6f7fcc4a5d4da136263d9552cf7a3fe5 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Mon, 25 Nov 2019 11:07:56 -0800 Subject: [PATCH 30/53] Add docstring to vault core and kvv1 --- src/vault/core.clj | 31 +++++++++++++++++++++++-------- src/vault/secrets/kvv1.clj | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/src/vault/core.clj b/src/vault/core.clj index b8af799..d294475 100644 --- a/src/vault/core.clj +++ b/src/vault/core.clj @@ -138,15 +138,23 @@ (list-secrets [client path] - "List the secrets located under a path.") + "Returns a vector of the secrets names located under a path. + + Params: + - `client`: `vault.client`, A client that handles vault auth and reading + - `path`: `String`, the path in vault of the secret you wish to read") (read-secret [client path opts] - "Reads a secret from a path. Returns the full map of stored secret data if - the secret exists, or throws an exception if not. + "Reads a resource from a path. Returns the full map of stored data if the resource exists, or throws an exception + if not. - Additional options may include: + Params: + - `client`: `vault.client`, A client that handles vault auth and reading + - `path`: `String`, the path in vault of the secret you wish to read + - `opts`: `map`, Further optional read described below. + Additional options may include: - `:not-found` If the requested path is not found, return this value instead of throwing an exception. @@ -160,13 +168,20 @@ (write-secret! [client path data] - "Writes secret data to a path. `data` should be a map. Returns a - boolean indicating whether the write was successful.") + "Writes secret data to a path. Returns a boolean indicating whether the write was successful. + + Params: + - `client`: `vault.client`, A client that handles vault auth and reading + - `path`: `String`, the path in vault of the secret you wish to read + - `data`: `map`, Further optional read described below.") (delete-secret! [client path] - "Removes secret data from a path. Returns a boolean indicating whether the - deletion was successful.")) + "Removes secret data from a path. Returns a boolean indicating whether the deletion was successful. + + Params: + - `client`: `vault.client`, A client that handles vault auth and reading + - `path`: `String`, the path in vault of the secret you wish to read")) (defprotocol WrappingClient diff --git a/src/vault/secrets/kvv1.clj b/src/vault/secrets/kvv1.clj index e4e98ce..8d548ed 100644 --- a/src/vault/secrets/kvv1.clj +++ b/src/vault/secrets/kvv1.clj @@ -4,11 +4,36 @@ (defn list-secrets + "Returns a vector of the secrets names located under a path. + + Params: + - `client`: `vault.client`, A client that handles vault auth and reading + - `path`: `String`, the path in vault of the secret you wish to read" [client path] (vault/list-secrets client path)) (defn read-secret + "Reads a secret from a path. Returns the full map of stored secret data if + the secret exists, or throws an exception if not. + + Params: + - `client`: `vault.client`, A client that handles vault auth and reading + - `path`: `String`, the path in vault of the secret you wish to read + - `opts`: `map`, Further optional read described below. + + Additional options may include: + + - `:not-found` + If the requested path is not found, return this value instead of throwing + an exception. + - `:renew` + Whether or not to renew this secret when the lease is near expiry. + - `:rotate` + Whether or not to rotate this secret when the lease is near expiry and + cannot be renewed. + - `:force-read` + Force the secret to be read from the server even if there is a valid lease cached." ([client path opts] (vault/read-secret client path opts)) ([client path] @@ -16,10 +41,21 @@ (defn write-secret! + "Writes secret data to a path. Returns a boolean indicating whether the write was successful. + + Params: + - `client`: `vault.client`, A client that handles vault auth and reading + - `path`: `String`, the path in vault of the secret you wish to read + - `data`: `map`, The data you wish to write to the given secret path." [client path data] (vault/write-secret! client path data)) (defn delete-secret! + "Removes secret data from a path. Returns a boolean indicating whether the deletion was successful. + + Params: + - `client`: `vault.client`, A client that handles vault auth and reading + - `path`: `String`, the path in vault of the secret you wish to read" [client path] (vault/delete-secret! client path)) From 95f45a079e6ccddef039844436c7c52a0c59aa1d Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Mon, 25 Nov 2019 12:07:26 -0800 Subject: [PATCH 31/53] Add kvv2 docstrings --- src/vault/core.clj | 19 ++++++------ src/vault/secrets/kvv1.clj | 16 +++++----- src/vault/secrets/kvv2.clj | 61 ++++++++++++++++++++++++++++++++++---- 3 files changed, 73 insertions(+), 23 deletions(-) diff --git a/src/vault/core.clj b/src/vault/core.clj index d294475..f934231 100644 --- a/src/vault/core.clj +++ b/src/vault/core.clj @@ -134,15 +134,16 @@ (defprotocol SecretEngine "Basic API for listing, reading, and writing secrets. - `eng` is a keyword representing the secret engine/mount" + **NOTE**: These are meant to be used as basic CRUD operations on Vault and is helpful for writing new Secret Engines. + End users will likely want to use Secret Engines directly (see `vault.secrets`)" (list-secrets [client path] "Returns a vector of the secrets names located under a path. Params: - - `client`: `vault.client`, A client that handles vault auth and reading - - `path`: `String`, the path in vault of the secret you wish to read") + - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops + - `path`: `String`, the path in vault of the secret you wish to list secrets at") (read-secret [client path opts] @@ -150,7 +151,7 @@ if not. Params: - - `client`: `vault.client`, A client that handles vault auth and reading + - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops - `path`: `String`, the path in vault of the secret you wish to read - `opts`: `map`, Further optional read described below. @@ -171,17 +172,17 @@ "Writes secret data to a path. Returns a boolean indicating whether the write was successful. Params: - - `client`: `vault.client`, A client that handles vault auth and reading - - `path`: `String`, the path in vault of the secret you wish to read - - `data`: `map`, Further optional read described below.") + - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops + - `path`: `String`, the path in vault of the secret you wish to write the secret to + - `data`: `map`, the data you wish to write to the given path.") (delete-secret! [client path] "Removes secret data from a path. Returns a boolean indicating whether the deletion was successful. Params: - - `client`: `vault.client`, A client that handles vault auth and reading - - `path`: `String`, the path in vault of the secret you wish to read")) + - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops + - `path`: `String`, the path in vault of the secret you wish to delete the secret from")) (defprotocol WrappingClient diff --git a/src/vault/secrets/kvv1.clj b/src/vault/secrets/kvv1.clj index 8d548ed..7015cad 100644 --- a/src/vault/secrets/kvv1.clj +++ b/src/vault/secrets/kvv1.clj @@ -1,4 +1,5 @@ (ns vault.secrets.kvv1 + "Interface for communicating with a Vault key value version 1 secret store (generic)" (:require [vault.core :as vault])) @@ -7,8 +8,8 @@ "Returns a vector of the secrets names located under a path. Params: - - `client`: `vault.client`, A client that handles vault auth and reading - - `path`: `String`, the path in vault of the secret you wish to read" + - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops + - `path`: `String`, the path in vault of the secret you wish to list secrets at" [client path] (vault/list-secrets client path)) @@ -18,12 +19,11 @@ the secret exists, or throws an exception if not. Params: - - `client`: `vault.client`, A client that handles vault auth and reading + - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops - `path`: `String`, the path in vault of the secret you wish to read - `opts`: `map`, Further optional read described below. Additional options may include: - - `:not-found` If the requested path is not found, return this value instead of throwing an exception. @@ -44,8 +44,8 @@ "Writes secret data to a path. Returns a boolean indicating whether the write was successful. Params: - - `client`: `vault.client`, A client that handles vault auth and reading - - `path`: `String`, the path in vault of the secret you wish to read + - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops + - `path`: `String`, the path in vault of the secret you wish to write the secret to - `data`: `map`, The data you wish to write to the given secret path." [client path data] (vault/write-secret! client path data)) @@ -55,7 +55,7 @@ "Removes secret data from a path. Returns a boolean indicating whether the deletion was successful. Params: - - `client`: `vault.client`, A client that handles vault auth and reading - - `path`: `String`, the path in vault of the secret you wish to read" + - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops + - `path`: `String`, the path in vault of the secret you wish to delete" [client path] (vault/delete-secret! client path)) diff --git a/src/vault/secrets/kvv2.clj b/src/vault/secrets/kvv2.clj index 2f2273b..aef764a 100644 --- a/src/vault/secrets/kvv2.clj +++ b/src/vault/secrets/kvv2.clj @@ -1,4 +1,5 @@ (ns vault.secrets.kvv2 + "Interface for communicating with a Vault key value version 2 secret store (kv)" (:require [vault.api-util :as api-util] [vault.core :as vault]) @@ -8,6 +9,25 @@ (defn read-secret + "Reads a secret from a path. Returns the full map of stored secret data if + the secret exists, or throws an exception if not. + + Params: + - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops + - `path`: `String`, the path in vault of the secret you wish to read + - `opts`: `map`, Further optional read described below. + + Additional options may include: + - `:not-found`, `any` + If the requested path is not found, return this value instead of throwing + an exception. + - `:renew`, `boolean` + Whether or not to renew this secret when the lease is near expiry. + - `:rotate`, `boolean` + Whether or not to rotate this secret when the lease is near expiry and + cannot be renewed. + - `:force-read`, `boolean` + Force the secret to be read from the server even if there is a valid lease cached." ([client mount path opts] (try (:data (vault/read-secret client (str mount "/data/" path) (dissoc opts :not-found))) @@ -23,17 +43,46 @@ (defn write-secret! + "Writes secret data to a path. Returns a boolean indicating whether the write was successful. + + Params: + - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops + - `mount`: `String`, the path in vault of the secret engine you wish to write a secret in + - `path`: `String`, the path of the secret you wish to write the data to + - `data`: `map`, the secret data you wish to write" [client mount path data] (let [result (vault/write-secret! client (str mount "/data/" path) {:data data})] (or (:data result) result))) (defn write-config! - [client mount data] - (vault/write-secret! client (str mount "/config") data)) + "Configures backend level settings that are applied to every key in the key-value store for a given secret engine. + Returns a boolean indicating whether the write was successful. + + Params: + - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops + - `mount`: `String`, the path in vault of the secret engine you wish to configure + - `config`: `map`, the configurations you wish to write. + + Configuration options are: + -`:max_versions`: `int`, The number of versions to keep per key. This value applies to all keys, but a key's + metadata setting can overwrite this value. Once a key has more than the configured allowed versions the oldest + version will be permanently deleted. Defaults to 10. + - `:cas_required`: `boolean`, – If true all keys will require the cas parameter to be set on all write requests. + - `:delete_version_after` `String` – If set, specifies the length of time before a version is deleted. + Accepts Go duration format string." + + [client mount config] + (vault/write-secret! client (str mount "/config") config)) (defn read-config + "Returns the current configuration for the secrets backend at the given path (mount) + + Params: + - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops + - `mount`: `String`, the path in vault of the secret engine you wish to read configurations for + - `opts`: `map`, options to affect the read call, see `vault.core/read-secret` for more details" ([client mount opts] (vault/read-secret client (str mount "/config") opts)) ([client mount] @@ -44,10 +93,10 @@ "Performs a soft delete a secret. This marks the versions as deleted and will stop them from being returned from reads, but the underlying data will not be removed. A delete can be undone using the `undelete` path. - - `client`: the Vault client you wish to delete a secret in - - `mount`: the Vault secret mount (the part of the path which determines which secret engine is used) - - `path`: the path aligned to the secret you wish to delete - - `versions`: vector of the versions of that secret you wish to delete, defaults to deleting the latest version" + - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops + - `mount`: `String`, the Vault secret mount (the part of the path which determines which secret engine is used) + - `path`: `String`, the path aligned to the secret you wish to delete + - `versions`: `vector`, the versions of that secret you wish to delete, defaults to deleting the latest version" ([client mount path versions] (if (empty? versions) (vault/delete-secret! client (str mount "/data/" path)) From 964748691a599e978a241c87104bfa1540bb38d5 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Mon, 25 Nov 2019 13:24:31 -0800 Subject: [PATCH 32/53] Added info to the README about secret engines --- README.md | 49 ++++++++++++++++++++++++++--- vault-clj_multi-engine_support.png | Bin 0 -> 39611 bytes 2 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 vault-clj_multi-engine_support.png diff --git a/README.md b/README.md index 7b554ea..89b64b1 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,10 @@ Leiningen, add the following dependency to your project definition: :lease-timer nil :leases #} -=> (vault/read-secret client "secret/foo/bar") +; Pull in the secret engine you wish to use: +=> (require '[vault.secrets.kvv1 :as kvv1]) + +=> (kvv1/read-secret client "secret/foo/bar") {:data "baz qux"} ``` @@ -57,10 +60,48 @@ secret fixture data. => (def mock-client (vault/new-client "mock:dev/secrets.edn")) +; Pull in the secret engine you wish to use: +=> (require '[vault.secrets.kvv1 :as kvv1]) + => (vault/read-secret mock-client "secret/service/foo/login") {:user "foo", :pass "abc123"} ``` +## Secret Engines +Vault supports many different [secret engines](https://www.vaultproject.io/docs/secrets/), each with very different +capabilities. For the most part, secrets engine behave similar to virtual filesystems, supporting CRUD operations. +Secret engines are very flexible, so please check out the [Vault docs](https://www.vaultproject.io/docs/secrets/) +for more info. + +**You should require these for any operations involving secrets in Vault, preferring them to the basic CRUD operations +exposed in `vault.core`** + +### Currently Supported Secret Engines + +#### [KV V1](https://www.vaultproject.io/docs/secrets/kv/kv-v1.html) + +```clojure +(require '[vault.secrets.kvv1 :as kvv1]) +``` + +#### [KV V2](https://www.vaultproject.io/docs/secrets/kv/kv-v2.html) + +```clojure +(require '[vault.secrets.kvv2 :as kvv2]) +``` + +### Adding your own Secret Engines +Custom secret engines can be added without contributing to `vault-clj`, but we appreciate PRs adding support for new +engines! + +Most operations on Vault secret engines can break down into some combination of logical CRUD operations that the Vault +clients expose. These CRUD operations are outlined by the `vault.core/SecretEngine` protocol. This allows our mocking +to work out of the box for some operations if engines are written to send Vault API calls through the client, as the +diagram below describes: + +![vault-clj Multi-engine Support](./vault-clj_multi-engine_support.png) + + ## Environment Resolution In order to abstract away the source of sensitive configuration variables @@ -82,10 +123,10 @@ client and resolve a map of config variables to their secret values. [:foo-user :foo-pass :bar]) {:foo-user "foo" :foo-pass "abc123" - :bar "direct-value} + :bar "direct-value"} ``` -## Mount Points +## Auth Mount Points The auth mount point configuration can be used to address any of the auth methods under a custom mount point. @@ -95,7 +136,7 @@ The auth mount point configuration can be used to address any of the :auth-mount-point "auth/mountpath/" :lease-renewal-window 00 :lease-check-period 00 - :lease-check-jitter 00)) + :lease-check-jitter 00))) ``` diff --git a/vault-clj_multi-engine_support.png b/vault-clj_multi-engine_support.png new file mode 100644 index 0000000000000000000000000000000000000000..e5da593285227e01e27a7aa13adda3189791fbac GIT binary patch literal 39611 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 Date: Tue, 26 Nov 2019 15:06:55 -0800 Subject: [PATCH 33/53] Moved vault.api-util -> vault.client.api-util --- src/vault/authenticate.clj | 2 +- src/vault/{ => client}/api_util.clj | 2 +- src/vault/client/http.clj | 2 +- src/vault/lease.clj | 4 ++-- src/vault/secrets/kvv2.clj | 2 +- test/vault/client/http_test.clj | 2 +- test/vault/secrets/kvv1_test.clj | 2 +- test/vault/secrets/kvv2_test.clj | 4 ++-- 8 files changed, 10 insertions(+), 10 deletions(-) rename src/vault/{ => client}/api_util.clj (99%) diff --git a/src/vault/authenticate.clj b/src/vault/authenticate.clj index bd02d0c..ebb4ec4 100644 --- a/src/vault/authenticate.clj +++ b/src/vault/authenticate.clj @@ -3,7 +3,7 @@ (:require [clojure.string :as str] [clojure.tools.logging :as log] - [vault.api-util :as api-util] + [vault.client.api-util :as api-util] [vault.lease :as lease])) diff --git a/src/vault/api_util.clj b/src/vault/client/api_util.clj similarity index 99% rename from src/vault/api_util.clj rename to src/vault/client/api_util.clj index 2c678ee..a786672 100644 --- a/src/vault/api_util.clj +++ b/src/vault/client/api_util.clj @@ -1,4 +1,4 @@ -(ns vault.api-util +(ns vault.client.api-util (:require [cheshire.core :as json] [clj-http.client :as http] diff --git a/src/vault/client/http.clj b/src/vault/client/http.clj index ddb7dfd..817488c 100644 --- a/src/vault/client/http.clj +++ b/src/vault/client/http.clj @@ -4,7 +4,7 @@ [clojure.string :as str] [clojure.tools.logging :as log] [com.stuartsierra.component :as component] - [vault.api-util :as api-util] + [vault.client.api-util :as api-util] [vault.authenticate :as authenticate] [vault.core :as vault] [vault.lease :as lease] diff --git a/src/vault/lease.clj b/src/vault/lease.clj index 6a54ed2..b0853e4 100644 --- a/src/vault/lease.clj +++ b/src/vault/lease.clj @@ -3,7 +3,7 @@ (:require [clojure.string :as str] [clojure.tools.logging :as log] - [vault.api-util :as api-util] + [vault.client.api-util :as api-util] [vault.core :as vault]) (:import java.time.Instant)) @@ -169,7 +169,7 @@ (:lease-id new-info)) (watch-fn new-info))))) -;; Larger scale lease logic +;; ----- Lease operations that work on the client level ----------------------- (defn ^:no-doc try-renew-lease! "Attempts to renew the given secret lease. Updates the lease store or catches diff --git a/src/vault/secrets/kvv2.clj b/src/vault/secrets/kvv2.clj index aef764a..a00bd52 100644 --- a/src/vault/secrets/kvv2.clj +++ b/src/vault/secrets/kvv2.clj @@ -1,7 +1,7 @@ (ns vault.secrets.kvv2 "Interface for communicating with a Vault key value version 2 secret store (kv)" (:require - [vault.api-util :as api-util] + [vault.client.api-util :as api-util] [vault.core :as vault]) (:import (clojure.lang diff --git a/test/vault/client/http_test.clj b/test/vault/client/http_test.clj index d09cf73..9a6927b 100644 --- a/test/vault/client/http_test.clj +++ b/test/vault/client/http_test.clj @@ -1,7 +1,7 @@ (ns vault.client.http-test (:require [clojure.test :refer :all] - [vault.api-util :as api-util] + [vault.client.api-util :as api-util] [vault.authenticate :as authenticate] [vault.client.http :refer [http-client]] [vault.core :as vault] diff --git a/test/vault/secrets/kvv1_test.clj b/test/vault/secrets/kvv1_test.clj index 4e01b7d..274bbad 100644 --- a/test/vault/secrets/kvv1_test.clj +++ b/test/vault/secrets/kvv1_test.clj @@ -70,7 +70,7 @@ (catch ExceptionInfo e (is (= {:errors nil :status 404 - :type :vault.api-util/api-error} + :type :vault.client.api-util/api-error} (ex-data e))))))))) diff --git a/test/vault/secrets/kvv2_test.clj b/test/vault/secrets/kvv2_test.clj index 83278e7..8c2f3af 100644 --- a/test/vault/secrets/kvv2_test.clj +++ b/test/vault/secrets/kvv2_test.clj @@ -1,7 +1,7 @@ (ns vault.secrets.kvv2-test (:require [clojure.test :refer [testing deftest is]] - [vault.api-util :as api-util] + [vault.client.api-util :as api-util] [vault.client.http :as http-client] [vault.client.mock-test :as mock-test] [vault.core :as vault] @@ -80,7 +80,7 @@ (is (= :get (:method req))) (is (= (str vault-url "/v1/" mount "/data/different/path") (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) - (throw (ex-info "not found" {:errors [] :status 404 :type :vault.api-util/api-error})))] + (throw (ex-info "not found" {:errors [] :status 404 :type :vault.client.api-util/api-error})))] (try (is (= {:default-val :is-here} (vault-kvv2/read-secret From 5ba9b3d1851542a3c613959290b2eb7c5551934c Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Tue, 26 Nov 2019 15:13:11 -0800 Subject: [PATCH 34/53] Added changes to CHANGELOG --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 954214f..ff1405c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. This change log follows the conventions of [keepachangelog.com](http://keepachangelog.com/). ## [Unreleased] +- Large internal refactors that may result in unexpected behavior + [#35](https://github.com/amperity/vault-clj/pull/35) +- Added support for the KV V2 API + [#33](https://github.com/amperity/vault-clj/issues/33) +- Added support for externally defined secret engines + [#33](https://github.com/amperity/vault-clj/issues/33) +- Bugfix for mocking delete + [#35](https://github.com/amperity/vault-clj/pull/35) ... From c9396cf8c3cd81aca092b0efc079028d784d4994 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Wed, 4 Dec 2019 12:20:27 -0800 Subject: [PATCH 35/53] Adds support for kv-v2 read-metadata * Added supports-not-found macro --- src/vault/client/api_util.clj | 17 ++++++ src/vault/client/http.clj | 11 ++-- src/vault/client/mock.clj | 5 +- src/vault/secrets/kvv2.clj | 30 ++++++----- test/vault/client/http_test.clj | 2 +- test/vault/secrets/kvv2_test.clj | 61 ++++++++++++++++++++++ test/vault/secrets/secret-fixture-kvv2.edn | 12 ++++- 7 files changed, 115 insertions(+), 23 deletions(-) diff --git a/src/vault/client/api_util.clj b/src/vault/client/api_util.clj index a786672..8ae32d3 100644 --- a/src/vault/client/api_util.clj +++ b/src/vault/client/api_util.clj @@ -6,6 +6,8 @@ [clojure.tools.logging :as log] [clojure.walk :as walk]) (:import + (clojure.lang + ExceptionInfo) (java.security MessageDigest) (org.apache.commons.codec.binary @@ -13,6 +15,21 @@ ;; ## API Utilities +(defmacro supports-not-found + "Tries to-try, and if a 404 occurs, looks for :not-found in to-try-opts" + [to-try to-try-opts] + (let [try-map (gensym 'api-options)] + `(try + ~to-try + (catch ExceptionInfo ex# + (let [~try-map ~to-try-opts] + (if (and (contains? ~try-map :not-found) + (= ::api-error (:type (ex-data ex#))) + (= 404 (:status (ex-data ex#)))) + (:not-found ~try-map) + (throw ex#))))))) + + (defn ^:no-doc kebabify-keys "Rewrites keyword map keys with underscores changed to dashes." [value] diff --git a/src/vault/client/http.clj b/src/vault/client/http.clj index 817488c..5de7c48 100644 --- a/src/vault/client/http.clj +++ b/src/vault/client/http.clj @@ -4,8 +4,8 @@ [clojure.string :as str] [clojure.tools.logging :as log] [com.stuartsierra.component :as component] - [vault.client.api-util :as api-util] [vault.authenticate :as authenticate] + [vault.client.api-util :as api-util] [vault.core :as vault] [vault.lease :as lease] [vault.timer :as timer]) @@ -244,7 +244,7 @@ (lease/lookup leases path))] (when-not (lease/expired? lease) (:data lease))) - (try + (api-util/supports-not-found (let [response (api-util/api-request this :get path {}) info (assoc (api-util/clean-body response) :path path @@ -256,12 +256,7 @@ (lease/update! leases info) (:data info)) - (catch ExceptionInfo ex - (if (and (contains? opts :not-found) - (= ::api-util/api-error (:type (ex-data ex))) - (= 404 (:status (ex-data ex)))) - (:not-found opts) - (throw ex)))))) + opts))) (write-secret! diff --git a/src/vault/client/mock.clj b/src/vault/client/mock.clj index 8a62372..ec25b2b 100644 --- a/src/vault/client/mock.clj +++ b/src/vault/client/mock.clj @@ -4,6 +4,7 @@ [clojure.edn :as edn] [clojure.java.io :as io] [clojure.string :as str] + [vault.client.api-util :as api-util] [vault.core :as vault]) (:import java.net.URI @@ -189,7 +190,9 @@ (if (contains? opts :not-found) (:not-found opts) (throw (ex-info (str "No such secret: " path) - {:secret path}))))) + {:secret path + :type ::api-util/api-error + :status 404}))))) (write-secret! diff --git a/src/vault/secrets/kvv2.clj b/src/vault/secrets/kvv2.clj index a00bd52..95757de 100644 --- a/src/vault/secrets/kvv2.clj +++ b/src/vault/secrets/kvv2.clj @@ -2,10 +2,7 @@ "Interface for communicating with a Vault key value version 2 secret store (kv)" (:require [vault.client.api-util :as api-util] - [vault.core :as vault]) - (:import - (clojure.lang - ExceptionInfo))) + [vault.core :as vault])) (defn read-secret @@ -29,15 +26,9 @@ - `:force-read`, `boolean` Force the secret to be read from the server even if there is a valid lease cached." ([client mount path opts] - (try + (api-util/supports-not-found (:data (vault/read-secret client (str mount "/data/" path) (dissoc opts :not-found))) - - (catch ExceptionInfo ex - (if (and (contains? opts :not-found) - (= ::api-util/api-error (:type (ex-data ex))) - (= 404 (:status (ex-data ex)))) - (:not-found opts) - (throw ex))))) + opts)) ([client mount path] (read-secret client mount path nil))) @@ -89,6 +80,21 @@ (read-config client mount nil))) +(defn read-metadata + "Returns retrieves the metadata and versions for the secret at the specified path. + + Params: + - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops + - `path`: `String`, the path in vault of the secret you wish to read + - `opts`: `map`, options to affect the read call, see `vault.core/read-secret` for more details" + ([client mount path opts] + (api-util/supports-not-found + (vault/read-secret client (str mount "/metadata/" path) (dissoc opts :not-found)) + opts)) + ([client mount path] + (read-metadata client mount path nil))) + + (defn delete-secret! "Performs a soft delete a secret. This marks the versions as deleted and will stop them from being returned from reads, but the underlying data will not be removed. A delete can be undone using the `undelete` path. diff --git a/test/vault/client/http_test.clj b/test/vault/client/http_test.clj index 9a6927b..54939b3 100644 --- a/test/vault/client/http_test.clj +++ b/test/vault/client/http_test.clj @@ -1,8 +1,8 @@ (ns vault.client.http-test (:require [clojure.test :refer :all] - [vault.client.api-util :as api-util] [vault.authenticate :as authenticate] + [vault.client.api-util :as api-util] [vault.client.http :refer [http-client]] [vault.core :as vault] [vault.secrets.kvv1 :as vault-kvv1])) diff --git a/test/vault/secrets/kvv2_test.clj b/test/vault/secrets/kvv2_test.clj index 8c2f3af..481cf19 100644 --- a/test/vault/secrets/kvv2_test.clj +++ b/test/vault/secrets/kvv2_test.clj @@ -185,6 +185,51 @@ (is (false? (vault-kvv2/delete-secret! client mount path-passed-in [123]))))))))) +(deftest read-metadata + (let [data {:data + {:created_time "2018-03-22T02:24:06.945319214Z" + :current_version 3 + :max_versions 0 + :oldest_version 0 + :updated_time "2018-03-22T02:36:43.986212308Z" + :versions {:1 {:created_time "2018-03-22T02:24:06.945319214Z" + :deletion_time "" + :destroyed false} + :2 {:created_time "2018-03-22T02:36:33.954880664Z" + :deletion_time "" + :destroyed false} + :3 {:created_time "2018-03-22T02:36:43.986212308Z" + :deletion_time "" + :destroyed false}}}} + mount "mount" + path-passed-in "path/passed/in" + token-passed-in "fake-token" + vault-url "https://vault.example.amperity.com" + client (http-client/http-client vault-url)] + (vault/authenticate! client :token token-passed-in) + (testing "Sends correct request and responds correctly upon success" + (with-redefs + [clj-http.client/request + (fn [req] + (is (= :get (:method req))) + (is (= (str vault-url "/v1/" mount "/metadata/" path-passed-in) (:url req))) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) + {:body data + :status 200})] + (is (= (:data data) (vault-kvv2/read-metadata client mount path-passed-in))))) + (testing "Sends correct request and responds correctly when metadata not found" + (with-redefs + [clj-http.client/request + (fn [req] + (is (= :get (:method req))) + (is (= (str vault-url "/v1/" mount "/metadata/" path-passed-in) (:url req))) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) + (throw (ex-info "not found" {:errors [] :status 404 :type :vault.client.api-util/api-error})))] + (is (thrown? ExceptionInfo (vault-kvv2/read-metadata client mount path-passed-in {:force-read true}))) + (is (= 3 (vault-kvv2/read-metadata client mount path-passed-in {:not-found 3 + :force-read true}))))))) + + ;; -------- Mock Client ------------------------------------------------------- (defn mock-client-kvv2 @@ -222,6 +267,22 @@ (vault-kvv2/read-config client "mount"))) (is (true? (vault-kvv2/write-config! client "mount" config))) (is (= config (vault-kvv2/read-config client "mount"))))) + (testing "Mock client can write and read metadata" + (let [client (mock-client-kvv2)] + (is (thrown? ExceptionInfo + (vault-kvv2/read-metadata client "mount" "doesn't exist" {:force-read true}))) + (is (= {:data + {:created_time "2018-03-22T02:24:06.945319214Z" + :current_version 1 + :max_versions 0 + :oldest_version 0 + :updated_time "2018-03-22T02:36:43.986212308Z" + :versions {:1 {:created_time "2018-03-22T02:24:06.945319214Z" + :deletion_time "" + :destroyed false}}}} + (vault-kvv2/read-metadata client "mount" "identities" {:force-read true}))) + (is (= 5 (vault-kvv2/read-metadata client "mount" "doesn't exist" {:force-read true + :not-found 5}))))) (testing "Mock client returns true if path is found on delete for secret, false if not when no versions specified" (let [client (mock-client-kvv2)] (is (true? (vault-kvv2/delete-secret! client "mount" "identities"))) diff --git a/test/vault/secrets/secret-fixture-kvv2.edn b/test/vault/secrets/secret-fixture-kvv2.edn index 32347fc..bbd96a6 100644 --- a/test/vault/secrets/secret-fixture-kvv2.edn +++ b/test/vault/secrets/secret-fixture-kvv2.edn @@ -1,3 +1,13 @@ {"mount/data/identities" {:data {:batman "Bruce Wayne" - :captain-marvel "Carol Danvers"}}} + :captain-marvel "Carol Danvers"}} + "mount/metadata/identities" + {:data + {:created_time "2018-03-22T02:24:06.945319214Z" + :current_version 1 + :max_versions 0 + :oldest_version 0 + :updated_time "2018-03-22T02:36:43.986212308Z" + :versions {:1 {:created_time "2018-03-22T02:24:06.945319214Z" + :deletion_time "" + :destroyed false}}}}} From 0058ec4ca473534cdbbc14e2ef10a191ff45ead6 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Wed, 4 Dec 2019 14:33:01 -0800 Subject: [PATCH 36/53] Cleaned up support not found macro names --- src/vault/client/api_util.clj | 16 ++++++++-------- src/vault/client/http.clj | 2 +- src/vault/secrets/kvv2.clj | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/vault/client/api_util.clj b/src/vault/client/api_util.clj index 8ae32d3..c548baa 100644 --- a/src/vault/client/api_util.clj +++ b/src/vault/client/api_util.clj @@ -15,18 +15,18 @@ ;; ## API Utilities -(defmacro supports-not-found - "Tries to-try, and if a 404 occurs, looks for :not-found in to-try-opts" - [to-try to-try-opts] - (let [try-map (gensym 'api-options)] +(defmacro support-not-found + "Tries to perform the api call, and if a 404 occurs, looks for :not-found in on-fail-opts" + [vault-api-call on-fail-opts] + (let [fail-map (gensym 'api-options)] `(try - ~to-try + ~vault-api-call (catch ExceptionInfo ex# - (let [~try-map ~to-try-opts] - (if (and (contains? ~try-map :not-found) + (let [~fail-map ~on-fail-opts] + (if (and (contains? ~fail-map :not-found) (= ::api-error (:type (ex-data ex#))) (= 404 (:status (ex-data ex#)))) - (:not-found ~try-map) + (:not-found ~fail-map) (throw ex#))))))) diff --git a/src/vault/client/http.clj b/src/vault/client/http.clj index 5de7c48..6ac2423 100644 --- a/src/vault/client/http.clj +++ b/src/vault/client/http.clj @@ -244,7 +244,7 @@ (lease/lookup leases path))] (when-not (lease/expired? lease) (:data lease))) - (api-util/supports-not-found + (api-util/support-not-found (let [response (api-util/api-request this :get path {}) info (assoc (api-util/clean-body response) :path path diff --git a/src/vault/secrets/kvv2.clj b/src/vault/secrets/kvv2.clj index 95757de..e3ba4e7 100644 --- a/src/vault/secrets/kvv2.clj +++ b/src/vault/secrets/kvv2.clj @@ -26,7 +26,7 @@ - `:force-read`, `boolean` Force the secret to be read from the server even if there is a valid lease cached." ([client mount path opts] - (api-util/supports-not-found + (api-util/support-not-found (:data (vault/read-secret client (str mount "/data/" path) (dissoc opts :not-found))) opts)) ([client mount path] @@ -88,7 +88,7 @@ - `path`: `String`, the path in vault of the secret you wish to read - `opts`: `map`, options to affect the read call, see `vault.core/read-secret` for more details" ([client mount path opts] - (api-util/supports-not-found + (api-util/support-not-found (vault/read-secret client (str mount "/metadata/" path) (dissoc opts :not-found)) opts)) ([client mount path] From 63da09b7767c702131b53eee33fd027b4caf45ba Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Wed, 4 Dec 2019 15:38:11 -0800 Subject: [PATCH 37/53] Added read-metadata to kvv2 --- src/vault/secrets/kvv2.clj | 25 ++++++++++- test/vault/secrets/kvv2_test.clj | 51 ++++++++++++++++++---- test/vault/secrets/secret-fixture-kvv2.edn | 3 +- 3 files changed, 67 insertions(+), 12 deletions(-) diff --git a/src/vault/secrets/kvv2.clj b/src/vault/secrets/kvv2.clj index e3ba4e7..d671717 100644 --- a/src/vault/secrets/kvv2.clj +++ b/src/vault/secrets/kvv2.clj @@ -85,7 +85,8 @@ Params: - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops - - `path`: `String`, the path in vault of the secret you wish to read + - `mount`: `String`, the secret engine mount point you wish to read secret metadata in + - `path`: `String`, the path in vault of the secret you wish to read metadata for - `opts`: `map`, options to affect the read call, see `vault.core/read-secret` for more details" ([client mount path opts] (api-util/support-not-found @@ -95,6 +96,28 @@ (read-metadata client mount path nil))) +(defn write-metadata! + "Creates a new version of a secret at the specified location. If the value does not yet exist, the calling token + must have an ACL policy granting the create capability. If the value already exists, the calling token must have an + ACL policy granting the update capability. Returns a boolean indicating whether the write was successful. + + Params: + - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops + - `mount`: `String`, the secret engine mount point you wish to write secret metadata in + - `path`: `String`, the path in vault of the secret you wish to write metadata for' + - `metadata`: `map` the metadata you wish to write. + + Metadata options are: + -`:max_versions`: `int`, The number of versions to keep per key. This value applies to all keys, but a key's + metadata setting can overwrite this value. Once a key has more than the configured allowed versions the oldest + version will be permanently deleted. Defaults to 10. + -`:cas_required`: `boolean`, – If true all keys will require the cas parameter to be set on all write requests. + - :delete_version_after` `String` – If set, specifies the length of time before a version is deleted. + Accepts Go duration format string." + [client mount path metadata] + (vault/write-secret! client (str mount "/metadata/" path) metadata)) + + (defn delete-secret! "Performs a soft delete a secret. This marks the versions as deleted and will stop them from being returned from reads, but the underlying data will not be removed. A delete can be undone using the `undelete` path. diff --git a/test/vault/secrets/kvv2_test.clj b/test/vault/secrets/kvv2_test.clj index 481cf19..9513ebc 100644 --- a/test/vault/secrets/kvv2_test.clj +++ b/test/vault/secrets/kvv2_test.clj @@ -230,6 +230,38 @@ :force-read true}))))))) +(deftest write-metadata + (let [payload {:max_versions 5, + :cas_required false, + :delete_version_after "3h25m19s"} + mount "mount" + path-passed-in "path/passed/in" + token-passed-in "fake-token" + vault-url "https://vault.example.amperity.com" + client (http-client/http-client vault-url)] + (vault/authenticate! client :token token-passed-in) + (testing "Write metadata sends correct request and responds with true upon success" + (with-redefs + [clj-http.client/request + (fn [req] + (is (= :post (:method req))) + (is (= (str vault-url "/v1/" mount "/metadata/" path-passed-in) (:url req))) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) + (is (= payload (:form-params req))) + {:status 204})] + (is (= (true? (vault-kvv2/write-metadata! client mount path-passed-in payload)))))) + (testing "Write metadata sends correct request and responds with false upon failure" + (with-redefs + [clj-http.client/request + (fn [req] + (is (= :post (:method req))) + (is (= (str vault-url "/v1/" mount "/metadata/" path-passed-in) (:url req))) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) + (is (= payload (:form-params req))) + {:status 500})] + (is (= (false? (vault-kvv2/write-metadata! client mount path-passed-in payload)))))))) + + ;; -------- Mock Client ------------------------------------------------------- (defn mock-client-kvv2 @@ -271,16 +303,17 @@ (let [client (mock-client-kvv2)] (is (thrown? ExceptionInfo (vault-kvv2/read-metadata client "mount" "doesn't exist" {:force-read true}))) - (is (= {:data - {:created_time "2018-03-22T02:24:06.945319214Z" - :current_version 1 - :max_versions 0 - :oldest_version 0 - :updated_time "2018-03-22T02:36:43.986212308Z" - :versions {:1 {:created_time "2018-03-22T02:24:06.945319214Z" - :deletion_time "" - :destroyed false}}}} + (is (= {:created_time "2018-03-22T02:24:06.945319214Z" + :current_version 1 + :max_versions 0 + :oldest_version 0 + :updated_time "2018-03-22T02:36:43.986212308Z" + :versions {:1 {:created_time "2018-03-22T02:24:06.945319214Z" + :deletion_time "" + :destroyed false}}} (vault-kvv2/read-metadata client "mount" "identities" {:force-read true}))) + (is (= (true? (vault-kvv2/write-metadata! client "mount" "hello" {:max-versions 3})))) + (is (= 3 (:max-versions (vault-kvv2/read-metadata client "mount" "hello")))) (is (= 5 (vault-kvv2/read-metadata client "mount" "doesn't exist" {:force-read true :not-found 5}))))) (testing "Mock client returns true if path is found on delete for secret, false if not when no versions specified" diff --git a/test/vault/secrets/secret-fixture-kvv2.edn b/test/vault/secrets/secret-fixture-kvv2.edn index bbd96a6..b66d49e 100644 --- a/test/vault/secrets/secret-fixture-kvv2.edn +++ b/test/vault/secrets/secret-fixture-kvv2.edn @@ -2,7 +2,6 @@ {:data {:batman "Bruce Wayne" :captain-marvel "Carol Danvers"}} "mount/metadata/identities" - {:data {:created_time "2018-03-22T02:24:06.945319214Z" :current_version 1 :max_versions 0 @@ -10,4 +9,4 @@ :updated_time "2018-03-22T02:36:43.986212308Z" :versions {:1 {:created_time "2018-03-22T02:24:06.945319214Z" :deletion_time "" - :destroyed false}}}}} + :destroyed false}}}} From 0692c6a0a889997cb00af6639a2b27187e86e833 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Wed, 4 Dec 2019 16:03:32 -0800 Subject: [PATCH 38/53] Added delete metadata to kvv2 --- src/vault/secrets/kvv2.clj | 11 ++++++++++ test/vault/secrets/kvv2_test.clj | 36 +++++++++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/vault/secrets/kvv2.clj b/src/vault/secrets/kvv2.clj index d671717..3990c2d 100644 --- a/src/vault/secrets/kvv2.clj +++ b/src/vault/secrets/kvv2.clj @@ -132,3 +132,14 @@ (vault/write-secret! client (str mount "/delete/" path) {:versions versions}))) ([client mount path] (delete-secret! client mount path nil))) + + +(defn delete-metadata! + "Permanently deletes the key metadata and all version data for the specified key. + All version history will be removed. This cannot be undone. A boolean indicating deletion success is returned. + + - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops + - `mount`: `String`, the Vault secret mount (the part of the path which determines which secret engine is used) + - `path`: `String`, the path aligned to the secret you wish to delete all data for" + [client mount path] + (vault/delete-secret! client (str mount "/metadata/" path))) diff --git a/test/vault/secrets/kvv2_test.clj b/test/vault/secrets/kvv2_test.clj index 9513ebc..e0366a4 100644 --- a/test/vault/secrets/kvv2_test.clj +++ b/test/vault/secrets/kvv2_test.clj @@ -249,7 +249,7 @@ (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) (is (= payload (:form-params req))) {:status 204})] - (is (= (true? (vault-kvv2/write-metadata! client mount path-passed-in payload)))))) + (is (true? (vault-kvv2/write-metadata! client mount path-passed-in payload))))) (testing "Write metadata sends correct request and responds with false upon failure" (with-redefs [clj-http.client/request @@ -259,7 +259,34 @@ (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) (is (= payload (:form-params req))) {:status 500})] - (is (= (false? (vault-kvv2/write-metadata! client mount path-passed-in payload)))))))) + (is (false? (vault-kvv2/write-metadata! client mount path-passed-in payload))))))) + + +(deftest delete-metadata + (let [mount "mount" + path-passed-in "path/passed/in" + token-passed-in "fake-token" + vault-url "https://vault.example.amperity.com" + client (http-client/http-client vault-url)] + (vault/authenticate! client :token token-passed-in) + (testing "Sends correct request and responds correctly upon success" + (with-redefs + [clj-http.client/request + (fn [req] + (is (= :delete (:method req))) + (is (= (str vault-url "/v1/" mount "/metadata/" path-passed-in) (:url req))) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) + {:status 204})] + (is (true? (vault-kvv2/delete-metadata! client mount path-passed-in))))) + (testing "Sends correct request and responds correctly upon failure" + (with-redefs + [clj-http.client/request + (fn [req] + (is (= :delete (:method req))) + (is (= (str vault-url "/v1/" mount "/metadata/" path-passed-in) (:url req))) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) + {:status 500})] + (is (false? (vault-kvv2/delete-metadata! client mount path-passed-in))))))) ;; -------- Mock Client ------------------------------------------------------- @@ -312,7 +339,10 @@ :deletion_time "" :destroyed false}}} (vault-kvv2/read-metadata client "mount" "identities" {:force-read true}))) - (is (= (true? (vault-kvv2/write-metadata! client "mount" "hello" {:max-versions 3})))) + (is (true? (vault-kvv2/delete-metadata! client "mount" "identities"))) + (is (thrown? ExceptionInfo + (vault-kvv2/read-metadata client "mount" "identities" {:force-read true}))) + (is (true? (vault-kvv2/write-metadata! client "mount" "hello" {:max-versions 3}))) (is (= 3 (:max-versions (vault-kvv2/read-metadata client "mount" "hello")))) (is (= 5 (vault-kvv2/read-metadata client "mount" "doesn't exist" {:force-read true :not-found 5}))))) From 5181df610026e95babe0845aa4e03399d85de171 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Wed, 4 Dec 2019 16:10:49 -0800 Subject: [PATCH 39/53] Updated CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff1405c..7607cf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ This change log follows the conventions of [keepachangelog.com](http://keepachan [#35](https://github.com/amperity/vault-clj/pull/35) - Added support for the KV V2 API [#33](https://github.com/amperity/vault-clj/issues/33) + [#39](https://github.com/amperity/vault-clj/issues/39) - Added support for externally defined secret engines [#33](https://github.com/amperity/vault-clj/issues/33) - Bugfix for mocking delete From 305c44e4d2ee9bcb673b680ba7401db5d970589e Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Wed, 4 Dec 2019 16:20:43 -0800 Subject: [PATCH 40/53] Simplified support-not-found macro --- src/vault/client/api_util.clj | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/vault/client/api_util.clj b/src/vault/client/api_util.clj index c548baa..688d857 100644 --- a/src/vault/client/api_util.clj +++ b/src/vault/client/api_util.clj @@ -18,16 +18,15 @@ (defmacro support-not-found "Tries to perform the api call, and if a 404 occurs, looks for :not-found in on-fail-opts" [vault-api-call on-fail-opts] - (let [fail-map (gensym 'api-options)] - `(try - ~vault-api-call - (catch ExceptionInfo ex# - (let [~fail-map ~on-fail-opts] - (if (and (contains? ~fail-map :not-found) - (= ::api-error (:type (ex-data ex#))) - (= 404 (:status (ex-data ex#)))) - (:not-found ~fail-map) - (throw ex#))))))) + `(try + ~vault-api-call + (catch ExceptionInfo ex# + (let [api-fail-options# ~on-fail-opts] + (if (and (contains? api-fail-options# :not-found) + (= ::api-error (:type (ex-data ex#))) + (= 404 (:status (ex-data ex#)))) + (:not-found api-fail-options#) + (throw ex#)))))) (defn ^:no-doc kebabify-keys From d27c25ab14fc04aa3f4cacab7c17db7a3c0a6e3d Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Thu, 5 Dec 2019 11:50:43 -0800 Subject: [PATCH 41/53] Added destroy endpoint --- src/vault/client/http.clj | 2 +- src/vault/secrets/kvv2.clj | 13 ++++++++++++ test/vault/client/http_test.clj | 2 +- test/vault/secrets/kvv2_test.clj | 36 +++++++++++++++++++++++++++++++- 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/vault/client/http.clj b/src/vault/client/http.clj index 817488c..06d0016 100644 --- a/src/vault/client/http.clj +++ b/src/vault/client/http.clj @@ -4,8 +4,8 @@ [clojure.string :as str] [clojure.tools.logging :as log] [com.stuartsierra.component :as component] - [vault.client.api-util :as api-util] [vault.authenticate :as authenticate] + [vault.client.api-util :as api-util] [vault.core :as vault] [vault.lease :as lease] [vault.timer :as timer]) diff --git a/src/vault/secrets/kvv2.clj b/src/vault/secrets/kvv2.clj index a00bd52..1c2699c 100644 --- a/src/vault/secrets/kvv2.clj +++ b/src/vault/secrets/kvv2.clj @@ -89,6 +89,19 @@ (read-config client mount nil))) +(defn destroy-secret! + "Permanently removes the specified version data for the provided key and version numbers from the key-value store. + Returns a boolean indicating whether the destroy was successful. + + Params: + - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops + - `mount`: `String`, the path in vault of the secret engine you wish to configure + - `path`: `String`, the path aligned to the secret you wish to delete + - `versions`: `vector`, the versions you want to delete" + [client mount path versions] + (vault/write-secret! client (str mount "/destroy/" path) {:versions versions})) + + (defn delete-secret! "Performs a soft delete a secret. This marks the versions as deleted and will stop them from being returned from reads, but the underlying data will not be removed. A delete can be undone using the `undelete` path. diff --git a/test/vault/client/http_test.clj b/test/vault/client/http_test.clj index 9a6927b..54939b3 100644 --- a/test/vault/client/http_test.clj +++ b/test/vault/client/http_test.clj @@ -1,8 +1,8 @@ (ns vault.client.http-test (:require [clojure.test :refer :all] - [vault.client.api-util :as api-util] [vault.authenticate :as authenticate] + [vault.client.api-util :as api-util] [vault.client.http :refer [http-client]] [vault.core :as vault] [vault.secrets.kvv1 :as vault-kvv1])) diff --git a/test/vault/secrets/kvv2_test.clj b/test/vault/secrets/kvv2_test.clj index 8c2f3af..7e30600 100644 --- a/test/vault/secrets/kvv2_test.clj +++ b/test/vault/secrets/kvv2_test.clj @@ -185,6 +185,38 @@ (is (false? (vault-kvv2/delete-secret! client mount path-passed-in [123]))))))))) +(deftest destroy!-test + (let [mount "mount" + path-passed-in "path/passed/in" + token-passed-in "fake-token" + vault-url "https://vault.example.amperity.com" + client (http-client/http-client vault-url) + versions [1 2]] + (vault/authenticate! client :token token-passed-in) + (testing "Destroy secrets sends correct request and returns true upon success" + (with-redefs + [clj-http.client/request + (fn [req] + (is (= :post (:method req))) + (is (= (str vault-url "/v1/" mount "/destroy/" path-passed-in) (:url req))) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) + (is (= {:versions versions} + (:form-params req))) + {:status 204})] + (is (true? (vault-kvv2/destroy-secret! client mount path-passed-in versions))))) + (testing "Destroy secrets sends correct request and returns false upon failure" + (with-redefs + [clj-http.client/request + (fn [req] + (is (= :post (:method req))) + (is (= (str vault-url "/v1/" mount "/destroy/" path-passed-in) (:url req))) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) + (is (= {:versions [1]} + (:form-params req))) + {:status 500})] + (is (false? (vault-kvv2/destroy-secret! client mount path-passed-in [1]))))))) + + ;; -------- Mock Client ------------------------------------------------------- (defn mock-client-kvv2 @@ -229,4 +261,6 @@ (testing "Mock client always returns true on delete for secret when versions specified" (let [client (mock-client-kvv2)] (is (true? (vault-kvv2/delete-secret! client "mount" "identities" [1]))) - (is (true? (vault-kvv2/delete-secret! client "mount" "eggsactly" [4 5 6])))))) + (is (true? (vault-kvv2/delete-secret! client "mount" "eggsactly" [4 5 6]))))) + (testing "Mock client does not crash upon destroy" + (is (true? (vault-kvv2/destroy-secret! (mock-client-kvv2) "mount" "identities" [1]))))) From a5d0b5722787925e87a87c297937534b21c4452d Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Thu, 5 Dec 2019 11:56:52 -0800 Subject: [PATCH 42/53] Added undelete endpoint --- src/vault/secrets/kvv2.clj | 17 +++++++++++++-- test/vault/secrets/kvv2_test.clj | 36 +++++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/vault/secrets/kvv2.clj b/src/vault/secrets/kvv2.clj index 1c2699c..0f5c83e 100644 --- a/src/vault/secrets/kvv2.clj +++ b/src/vault/secrets/kvv2.clj @@ -96,12 +96,25 @@ Params: - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops - `mount`: `String`, the path in vault of the secret engine you wish to configure - - `path`: `String`, the path aligned to the secret you wish to delete - - `versions`: `vector`, the versions you want to delete" + - `path`: `String`, the path aligned to the secret you wish to destroy + - `versions`: `vector`, the versions you want to destroy" [client mount path versions] (vault/write-secret! client (str mount "/destroy/" path) {:versions versions})) +(defn undelete-secret! + "Undeletes the data for the provided version and path in the key-value store. This restores the data, allowing it to + be returned on get requests. + + Params: + - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops + - `mount`: `String`, the path in vault of the secret engine you wish to configure + - `path`: `String`, the path aligned to the secret you wish to undelete + - `versions`: `vector`, the versions you want to undelete" + [client mount path versions] + (vault/write-secret! client (str mount "/undelete/" path) {:versions versions})) + + (defn delete-secret! "Performs a soft delete a secret. This marks the versions as deleted and will stop them from being returned from reads, but the underlying data will not be removed. A delete can be undone using the `undelete` path. diff --git a/test/vault/secrets/kvv2_test.clj b/test/vault/secrets/kvv2_test.clj index 7e30600..c18dd5d 100644 --- a/test/vault/secrets/kvv2_test.clj +++ b/test/vault/secrets/kvv2_test.clj @@ -217,6 +217,38 @@ (is (false? (vault-kvv2/destroy-secret! client mount path-passed-in [1]))))))) +(deftest undelete-secret!-test + (let [mount "mount" + path-passed-in "path/passed/in" + token-passed-in "fake-token" + vault-url "https://vault.example.amperity.com" + client (http-client/http-client vault-url) + versions [1 2]] + (vault/authenticate! client :token token-passed-in) + (testing "Undelete secrets sends correct request and returns true upon success" + (with-redefs + [clj-http.client/request + (fn [req] + (is (= :post (:method req))) + (is (= (str vault-url "/v1/" mount "/undelete/" path-passed-in) (:url req))) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) + (is (= {:versions versions} + (:form-params req))) + {:status 204})] + (is (true? (vault-kvv2/undelete-secret! client mount path-passed-in versions))))) + (testing "Undelete secrets sends correct request and returns false upon failure" + (with-redefs + [clj-http.client/request + (fn [req] + (is (= :post (:method req))) + (is (= (str vault-url "/v1/" mount "/undelete/" path-passed-in) (:url req))) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) + (is (= {:versions [1]} + (:form-params req))) + {:status 500})] + (is (false? (vault-kvv2/undelete-secret! client mount path-passed-in [1]))))))) + + ;; -------- Mock Client ------------------------------------------------------- (defn mock-client-kvv2 @@ -263,4 +295,6 @@ (is (true? (vault-kvv2/delete-secret! client "mount" "identities" [1]))) (is (true? (vault-kvv2/delete-secret! client "mount" "eggsactly" [4 5 6]))))) (testing "Mock client does not crash upon destroy" - (is (true? (vault-kvv2/destroy-secret! (mock-client-kvv2) "mount" "identities" [1]))))) + (is (true? (vault-kvv2/destroy-secret! (mock-client-kvv2) "mount" "identities" [1])))) + (testing "Mock client does not crash upon undelete" + (is (true? (vault-kvv2/undelete-secret! (mock-client-kvv2) "mount" "identities" [1]))))) From 6709f3d9312627564a1fe3f6a38951f85c261dea Mon Sep 17 00:00:00 2001 From: Colin Lappala Date: Thu, 5 Dec 2019 14:24:11 -0800 Subject: [PATCH 43/53] consider expired leases rotatable this helps mitigate secret expiration (outside the control of the client such as in a network outage) that would otherwise be rotatable. before this change, a lease wasn't marked 'renewable false' during the run of manage-leases so it would just be swept. instead, this at least attempts to rotate first before sweeping it for being expired --- src/vault/lease.clj | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/vault/lease.clj b/src/vault/lease.clj index b0853e4..239df7b 100644 --- a/src/vault/lease.clj +++ b/src/vault/lease.clj @@ -153,7 +153,10 @@ [store window] (->> (list-leases store) (filter ::rotate) - (remove renewable?) + (filter (fn non-renewable? + [lease] + (or (expired? lease) + (not (renewable? lease))))) (filter #(expires-within? % window)))) From 5ae99b4500d810a002ff50f9d85fb0907cf01784 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Fri, 6 Dec 2019 11:48:16 -0800 Subject: [PATCH 44/53] not-found macro takes any num of sexprs in body --- src/vault/client/api_util.clj | 9 +++++---- src/vault/client/http.clj | 6 +++--- src/vault/secrets/kvv2.clj | 10 ++++------ 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/vault/client/api_util.clj b/src/vault/client/api_util.clj index 688d857..c3fb353 100644 --- a/src/vault/client/api_util.clj +++ b/src/vault/client/api_util.clj @@ -15,11 +15,12 @@ ;; ## API Utilities -(defmacro support-not-found - "Tries to perform the api call, and if a 404 occurs, looks for :not-found in on-fail-opts" - [vault-api-call on-fail-opts] +(defmacro supports-not-found + "Tries to perform the body, which likely includes an API call. If a `404` `::api-error` occurs, looks for and returns + the value of `:not-found` in `on-fail-opts` if present" + [on-fail-opts & body] `(try - ~vault-api-call + ~@body (catch ExceptionInfo ex# (let [api-fail-options# ~on-fail-opts] (if (and (contains? api-fail-options# :not-found) diff --git a/src/vault/client/http.clj b/src/vault/client/http.clj index 6ac2423..367a6e8 100644 --- a/src/vault/client/http.clj +++ b/src/vault/client/http.clj @@ -244,7 +244,8 @@ (lease/lookup leases path))] (when-not (lease/expired? lease) (:data lease))) - (api-util/support-not-found + (api-util/supports-not-found + opts (let [response (api-util/api-request this :get path {}) info (assoc (api-util/clean-body response) :path path @@ -255,8 +256,7 @@ path (:lease-duration info)) (lease/update! leases info) - (:data info)) - opts))) + (:data info))))) (write-secret! diff --git a/src/vault/secrets/kvv2.clj b/src/vault/secrets/kvv2.clj index 3990c2d..f3097af 100644 --- a/src/vault/secrets/kvv2.clj +++ b/src/vault/secrets/kvv2.clj @@ -26,9 +26,9 @@ - `:force-read`, `boolean` Force the secret to be read from the server even if there is a valid lease cached." ([client mount path opts] - (api-util/support-not-found - (:data (vault/read-secret client (str mount "/data/" path) (dissoc opts :not-found))) - opts)) + (api-util/supports-not-found + opts + (:data (vault/read-secret client (str mount "/data/" path) (dissoc opts :not-found))))) ([client mount path] (read-secret client mount path nil))) @@ -89,9 +89,7 @@ - `path`: `String`, the path in vault of the secret you wish to read metadata for - `opts`: `map`, options to affect the read call, see `vault.core/read-secret` for more details" ([client mount path opts] - (api-util/support-not-found - (vault/read-secret client (str mount "/metadata/" path) (dissoc opts :not-found)) - opts)) + (vault/read-secret client (str mount "/metadata/" path) opts)) ([client mount path] (read-metadata client mount path nil))) From ce1a5ecd963e323be70667dbd4ff1af18f793a68 Mon Sep 17 00:00:00 2001 From: Colin Lappala Date: Fri, 6 Dec 2019 15:07:06 -0800 Subject: [PATCH 45/53] fixup typo in tests --- test/vault/secrets/kvv1_test.clj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/vault/secrets/kvv1_test.clj b/test/vault/secrets/kvv1_test.clj index 274bbad..ccb196a 100644 --- a/test/vault/secrets/kvv1_test.clj +++ b/test/vault/secrets/kvv1_test.clj @@ -123,7 +123,7 @@ (fn [req] (is (= :delete (:method req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) - (is (= (str vault-url "/v1/" path-passed-in (:url req)))) + (is (= (str vault-url "/v1/" path-passed-in) (:url req))) {:status 204})] (is (true? (vault/delete-secret! client path-passed-in))))) (testing "Delete secret returns correctly upon failure, and sends correct request" @@ -132,7 +132,7 @@ (fn [req] (is (= :delete (:method req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) - (is (= (str vault-url "/v1/" path-passed-in (:url req)))) + (is (= (str vault-url "/v1/" path-passed-in) (:url req))) {:status 404})] (is (false? (vault/delete-secret! client path-passed-in))))))) From 400e965e4e9b959e62e1fd9950229c7d8f65227a Mon Sep 17 00:00:00 2001 From: Colin Lappala Date: Fri, 6 Dec 2019 16:33:01 -0800 Subject: [PATCH 46/53] test coverage over lease filtering --- test/vault/client/http_test.clj | 2 +- test/vault/lease_test.clj | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/test/vault/client/http_test.clj b/test/vault/client/http_test.clj index 9a6927b..54939b3 100644 --- a/test/vault/client/http_test.clj +++ b/test/vault/client/http_test.clj @@ -1,8 +1,8 @@ (ns vault.client.http-test (:require [clojure.test :refer :all] - [vault.client.api-util :as api-util] [vault.authenticate :as authenticate] + [vault.client.api-util :as api-util] [vault.client.http :refer [http-client]] [vault.core :as vault] [vault.secrets.kvv1 :as vault-kvv1])) diff --git a/test/vault/lease_test.clj b/test/vault/lease_test.clj index bad9932..eccd72d 100644 --- a/test/vault/lease_test.clj +++ b/test/vault/lease_test.clj @@ -55,6 +55,38 @@ "lookup of stored secret after expiry should return nil")))) +(deftest lease-filtering + (let [c (lease/new-store) + the-lease {:path "foo/bar" + :lease-id "foo/bar/12345" + :lease-duration 100 + :renewable true + :vault.lease/renew true + :vault.lease/rotate true + :vault.lease/issued (Instant/ofEpochMilli 1000) + :vault.lease/expiry (Instant/ofEpochMilli 101000)}] + (with-time 1000 + (lease/update! c {:path "foo/bar" + :data {:bar "baz"} + :lease-id "foo/bar/12345" + :lease-duration 100 + :renewable true + :renew true + :rotate true})) + (with-time 101001 + (is (= [the-lease] (lease/list-leases c)) + "Basic lease listing should work, and the data should match.") + (is (= [the-lease] (lease/rotatable-leases c 0)) + "Expired but rotatable lease should be considered rotatable")) + (with-time 100000 + (is (= [the-lease] (lease/renewable-leases c 2)), + "Renewable leases should be listed when not expired yet.") + (is (empty? (lease/renewable-leases c 1)), + "Renewable leases should not be listed when outside the given window.") + (is (empty? (lease/rotatable-leases c 0)) + "Non-expired, renewable leases should not be considered for rotation.")))) + + (deftest secret-invalidation (let [c (lease/new-store)] (is (some? (lease/update! c {:path "foo/bar" From 2d6b197a4c6cf0cd2a7e5cc5bd9eae3f2c984f50 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Tue, 10 Dec 2019 12:15:35 -0800 Subject: [PATCH 47/53] Added list secrets endpoint, also kvv2 now creates metadata for mock --- src/vault/client/mock.clj | 5 +- src/vault/secrets/kvv2.clj | 86 +++++++++++++++++++------------- test/vault/secrets/kvv2_test.clj | 31 ++++++++++++ 3 files changed, 85 insertions(+), 37 deletions(-) diff --git a/src/vault/client/mock.clj b/src/vault/client/mock.clj index ec25b2b..989b454 100644 --- a/src/vault/client/mock.clj +++ b/src/vault/client/mock.clj @@ -181,7 +181,10 @@ (list-secrets [this path] - (filter #(str/starts-with? % (str path)) (keys @memory))) + (->> (keys @memory) + (filter #(str/starts-with? % (str path))) + ;; TODO: Mock here relies on string replace to get correct result for kvv2, this is brittle and not extensible + (map #(str/replace % #"(?:\w+\/)+metadata\/" "")))) (read-secret diff --git a/src/vault/secrets/kvv2.clj b/src/vault/secrets/kvv2.clj index df730a6..b47d292 100644 --- a/src/vault/secrets/kvv2.clj +++ b/src/vault/secrets/kvv2.clj @@ -5,12 +5,24 @@ [vault.core :as vault])) +(defn list-secrets + "Returns a vector of the secrets names located under a path. + + Params: + - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops + - `mount`: `String`, the path in vault of the secret engine you wish to list secrets in + - `path`: `String`, the path in vault of the secret you wish to list secrets at" + [client mount path] + (vault/list-secrets client (str mount "/metadata/" path))) + + (defn read-secret "Reads a secret from a path. Returns the full map of stored secret data if the secret exists, or throws an exception if not. Params: - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops + - `mount`: `String`, the path in vault of the secret engine you wish to read a secret in - `path`: `String`, the path in vault of the secret you wish to read - `opts`: `map`, Further optional read described below. @@ -33,6 +45,42 @@ (read-secret client mount path nil))) +(defn read-metadata + "Returns retrieves the metadata and versions for the secret at the specified path. + + Params: + - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops + - `mount`: `String`, the secret engine mount point you wish to read secret metadata in + - `path`: `String`, the path in vault of the secret you wish to read metadata for + - `opts`: `map`, options to affect the read call, see `vault.core/read-secret` for more details" + ([client mount path opts] + (vault/read-secret client (str mount "/metadata/" path) opts)) + ([client mount path] + (read-metadata client mount path nil))) + + +(defn write-metadata! + "Creates a new version of a secret at the specified location. If the value does not yet exist, the calling token + must have an ACL policy granting the create capability. If the value already exists, the calling token must have an + ACL policy granting the update capability. Returns a boolean indicating whether the write was successful. + + Params: + - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops + - `mount`: `String`, the secret engine mount point you wish to write secret metadata in + - `path`: `String`, the path in vault of the secret you wish to write metadata for' + - `metadata`: `map` the metadata you wish to write. + + Metadata options are: + -`:max_versions`: `int`, The number of versions to keep per key. This value applies to all keys, but a key's + metadata setting can overwrite this value. Once a key has more than the configured allowed versions the oldest + version will be permanently deleted. Defaults to 10. + -`:cas_required`: `boolean`, – If true all keys will require the cas parameter to be set on all write requests. + - :delete_version_after` `String` – If set, specifies the length of time before a version is deleted. + Accepts Go duration format string." + [client mount path metadata] + (vault/write-secret! client (str mount "/metadata/" path) metadata)) + + (defn write-secret! "Writes secret data to a path. Returns a boolean indicating whether the write was successful. @@ -42,6 +90,8 @@ - `path`: `String`, the path of the secret you wish to write the data to - `data`: `map`, the secret data you wish to write" [client mount path data] + ;; this gets the mock client to also write metadata, and shouldn't meaningfully affect the http client + (write-metadata! client mount path {}) (let [result (vault/write-secret! client (str mount "/data/" path) {:data data})] (or (:data result) result))) @@ -80,42 +130,6 @@ (read-config client mount nil))) -(defn read-metadata - "Returns retrieves the metadata and versions for the secret at the specified path. - - Params: - - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops - - `mount`: `String`, the secret engine mount point you wish to read secret metadata in - - `path`: `String`, the path in vault of the secret you wish to read metadata for - - `opts`: `map`, options to affect the read call, see `vault.core/read-secret` for more details" - ([client mount path opts] - (vault/read-secret client (str mount "/metadata/" path) opts)) - ([client mount path] - (read-metadata client mount path nil))) - - -(defn write-metadata! - "Creates a new version of a secret at the specified location. If the value does not yet exist, the calling token - must have an ACL policy granting the create capability. If the value already exists, the calling token must have an - ACL policy granting the update capability. Returns a boolean indicating whether the write was successful. - - Params: - - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops - - `mount`: `String`, the secret engine mount point you wish to write secret metadata in - - `path`: `String`, the path in vault of the secret you wish to write metadata for' - - `metadata`: `map` the metadata you wish to write. - - Metadata options are: - -`:max_versions`: `int`, The number of versions to keep per key. This value applies to all keys, but a key's - metadata setting can overwrite this value. Once a key has more than the configured allowed versions the oldest - version will be permanently deleted. Defaults to 10. - -`:cas_required`: `boolean`, – If true all keys will require the cas parameter to be set on all write requests. - - :delete_version_after` `String` – If set, specifies the length of time before a version is deleted. - Accepts Go duration format string." - [client mount path metadata] - (vault/write-secret! client (str mount "/metadata/" path) metadata)) - - (defn destroy-secret! "Permanently removes the specified version data for the provided key and version numbers from the key-value store. Returns a boolean indicating whether the destroy was successful. diff --git a/test/vault/secrets/kvv2_test.clj b/test/vault/secrets/kvv2_test.clj index b8d2bbd..97c8732 100644 --- a/test/vault/secrets/kvv2_test.clj +++ b/test/vault/secrets/kvv2_test.clj @@ -11,6 +11,30 @@ ExceptionInfo))) +(deftest list-secrets-test + (let [path "path/passed/in" + token-passed-in "fake-token" + vault-url "https://vault.example.amperity.com" + client (http-client/http-client vault-url) + response {:auth nil + :data {:keys ["foo" "foo/"]} + :lease_duration 2764800 + :lease-id "" + :renewable false}] + (vault/authenticate! client :token token-passed-in) + (testing "List secrets has correct response and sends correct request" + (with-redefs + [clj-http.client/request + (fn [req] + (is (= :get (:method req))) + (is (= (str vault-url "/v1/listmount/metadata/" path) (:url req))) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) + (is (true? (-> req :query-params :list))) + {:body response})] + (is (= ["foo" "foo/"] + (vault-kvv2/list-secrets client "listmount" path))))))) + + (deftest write-config!-test (let [mount "mount" token-passed-in "fake-token" @@ -418,6 +442,13 @@ (let [client (mock-client-kvv2)] (is (true? (vault-kvv2/delete-secret! client "mount" "identities" [1]))) (is (true? (vault-kvv2/delete-secret! client "mount" "eggsactly" [4 5 6]))))) + (testing "Mock can list secrets from their associated metadata" + (let [client (mock-client-kvv2)] + (is (empty? (vault-kvv2/list-secrets client "hello" "yes"))) + (is (true? (vault-kvv2/write-secret! client "mount" "hello" {:and-i-say "goodbye"}))) + ;; Paths are good enough for mock right now, but be aware they are current + (is (= ["identities" "hello"] + (into [] (vault-kvv2/list-secrets client "mount" "")))))) (testing "Mock client does not crash upon destroy" (is (true? (vault-kvv2/destroy-secret! (mock-client-kvv2) "mount" "identities" [1])))) (testing "Mock client does not crash upon undelete" From d73dce5dc156fa593b716b9d362f3fc2cb100903 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Tue, 10 Dec 2019 12:34:25 -0800 Subject: [PATCH 48/53] Updated kvv2 write test because of metadata call --- test/vault/secrets/kvv2_test.clj | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/test/vault/secrets/kvv2_test.clj b/test/vault/secrets/kvv2_test.clj index 97c8732..7865434 100644 --- a/test/vault/secrets/kvv2_test.clj +++ b/test/vault/secrets/kvv2_test.clj @@ -139,25 +139,33 @@ (with-redefs [clj-http.client/request (fn [req] - (is (= :post (:method req))) - (is (= (str vault-url "/v1/" mount "/data/" path-passed-in) (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) - (is (= {:data write-data} - (:form-params req))) - {:body create-success - :status 200})] + (is (= :post (:method req))) + (if (= (str vault-url "/v1/" mount "/metadata/" path-passed-in) (:url req)) + (do (is (= {} (:form-params req))) + {:errors [] + :status 200}) + (do (is (= (str vault-url "/v1/" mount "/data/" path-passed-in) (:url req))) + (is (= {:data write-data} + (:form-params req))) + {:body create-success + :status 200})))] (is (= (:data create-success) (vault-kvv2/write-secret! client mount path-passed-in write-data))))) (testing "Write secrets sends correct request and returns false upon failure" (with-redefs [clj-http.client/request (fn [req] - (is (= :post (:method req))) - (is (= (str vault-url "/v1/" mount "/data/other-path") (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) - (is (= {:data write-data} - (:form-params req))) - {:errors [] - :status 404})] + (is (= :post (:method req))) + (if (= (str vault-url "/v1/" mount "/metadata/other-path") (:url req)) + (do (is (= {} (:form-params req))) + {:errors [] + :status 200}) + (do (is (= (str vault-url "/v1/" mount "/data/other-path") (:url req))) + (is (= {:data write-data} + (:form-params req))) + {:errors [] + :status 500})))] (is (false? (vault-kvv2/write-secret! client mount "other-path" write-data))))))) From 7510323f921788feb680e7e23b0f599ec6c726b1 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Wed, 11 Dec 2019 16:36:27 -0800 Subject: [PATCH 49/53] Added ability to look up specific versions --- src/vault/client/http.clj | 9 +++++++-- src/vault/client/mock.clj | 6 +++++- src/vault/core.clj | 2 +- src/vault/secrets/kvv2.clj | 15 ++++++++++++--- test/vault/secrets/kvv2_test.clj | 10 ++++++++++ 5 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/vault/client/http.clj b/src/vault/client/http.clj index 367a6e8..3b2dcf8 100644 --- a/src/vault/client/http.clj +++ b/src/vault/client/http.clj @@ -239,14 +239,14 @@ (read-secret - [this path opts] + [this path opts merge-req] (or (when-let [lease (and (not (:force-read opts)) (lease/lookup leases path))] (when-not (lease/expired? lease) (:data lease))) (api-util/supports-not-found opts - (let [response (api-util/api-request this :get path {}) + (let [response (api-util/api-request this :get path merge-req) info (assoc (api-util/clean-body response) :path path :renew (:renew opts) @@ -259,6 +259,11 @@ (:data info))))) + (read-secret + [this path opts] + (.read-secret this path opts {})) + + (write-secret! [this path data] (let [response (api-util/api-request diff --git a/src/vault/client/mock.clj b/src/vault/client/mock.clj index 989b454..e92fc6d 100644 --- a/src/vault/client/mock.clj +++ b/src/vault/client/mock.clj @@ -188,7 +188,7 @@ (read-secret - [this path opts] + [this path opts merge-req] (or (get @memory path) (if (contains? opts :not-found) (:not-found opts) @@ -197,6 +197,10 @@ :type ::api-util/api-error :status 404}))))) + (read-secret + [this path opts] + (.read-secret this path opts {})) + (write-secret! [this path data] diff --git a/src/vault/core.clj b/src/vault/core.clj index f934231..00ab449 100644 --- a/src/vault/core.clj +++ b/src/vault/core.clj @@ -146,7 +146,7 @@ - `path`: `String`, the path in vault of the secret you wish to list secrets at") (read-secret - [client path opts] + [client path opts merge-req] [client path opts] "Reads a resource from a path. Returns the full map of stored data if the resource exists, or throws an exception if not. diff --git a/src/vault/secrets/kvv2.clj b/src/vault/secrets/kvv2.clj index b47d292..109fb8a 100644 --- a/src/vault/secrets/kvv2.clj +++ b/src/vault/secrets/kvv2.clj @@ -22,7 +22,7 @@ Params: - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops - - `mount`: `String`, the path in vault of the secret engine you wish to read a secret in + - `mount`: `String`, the path in vault of the secret engine you wish to read a secret in - `path`: `String`, the path in vault of the secret you wish to read - `opts`: `map`, Further optional read described below. @@ -36,11 +36,20 @@ Whether or not to rotate this secret when the lease is near expiry and cannot be renewed. - `:force-read`, `boolean` - Force the secret to be read from the server even if there is a valid lease cached." + Force the secret to be read from the server even if there is a valid lease cached. + - `version`:, `nat num`, the version of the secret you wish to read" + ([client mount path opts] (api-util/supports-not-found opts - (:data (vault/read-secret client (str mount "/data/" path) (dissoc opts :not-found))))) + (:data + (vault/read-secret + client + (str mount "/data/" path) + (dissoc opts :not-found) + (if (:version opts) + {:query-params {"version" (:version opts)}} + {}))))) ([client mount path] (read-secret client mount path nil))) diff --git a/test/vault/secrets/kvv2_test.clj b/test/vault/secrets/kvv2_test.clj index 7865434..53aa6d8 100644 --- a/test/vault/secrets/kvv2_test.clj +++ b/test/vault/secrets/kvv2_test.clj @@ -97,6 +97,16 @@ (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) {:body lookup-response-valid-path})] (is (= {:foo "bar"} (vault-kvv2/read-secret client mount path-passed-in))))) + (testing "Read secrets sends correct request and responds correctly if secret with version is successfully located" + (with-redefs + [clj-http.client/request + (fn [req] + (is (= :get (:method req))) + (is (= (str vault-url "/v1/" mount "/data/" path-passed-in) (:url req))) + (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) + (is (= {"version" 3} (:query-params req))) + {:body lookup-response-valid-path})] + (is (= {:foo "bar"} (vault-kvv2/read-secret client mount path-passed-in {:version 3 :force-read true}))))) (testing "Read secrets sends correct request and responds correctly if no secret is found" (with-redefs [clj-http.client/request From 8f54bbe85b0758ed9b3a3aafbddc1435d5cfde74 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Wed, 11 Dec 2019 17:43:22 -0800 Subject: [PATCH 50/53] Added :request-opts key to core read secret opts --- src/vault/client/http.clj | 9 ++------- src/vault/client/mock.clj | 6 +----- src/vault/core.clj | 6 ++++-- src/vault/secrets/kvv2.clj | 10 ++++------ 4 files changed, 11 insertions(+), 20 deletions(-) diff --git a/src/vault/client/http.clj b/src/vault/client/http.clj index 3b2dcf8..7bc6456 100644 --- a/src/vault/client/http.clj +++ b/src/vault/client/http.clj @@ -239,14 +239,14 @@ (read-secret - [this path opts merge-req] + [this path opts] (or (when-let [lease (and (not (:force-read opts)) (lease/lookup leases path))] (when-not (lease/expired? lease) (:data lease))) (api-util/supports-not-found opts - (let [response (api-util/api-request this :get path merge-req) + (let [response (api-util/api-request this :get path (:request-opts opts)) info (assoc (api-util/clean-body response) :path path :renew (:renew opts) @@ -259,11 +259,6 @@ (:data info))))) - (read-secret - [this path opts] - (.read-secret this path opts {})) - - (write-secret! [this path data] (let [response (api-util/api-request diff --git a/src/vault/client/mock.clj b/src/vault/client/mock.clj index e92fc6d..989b454 100644 --- a/src/vault/client/mock.clj +++ b/src/vault/client/mock.clj @@ -188,7 +188,7 @@ (read-secret - [this path opts merge-req] + [this path opts] (or (get @memory path) (if (contains? opts :not-found) (:not-found opts) @@ -197,10 +197,6 @@ :type ::api-util/api-error :status 404}))))) - (read-secret - [this path opts] - (.read-secret this path opts {})) - (write-secret! [this path data] diff --git a/src/vault/core.clj b/src/vault/core.clj index 00ab449..7628300 100644 --- a/src/vault/core.clj +++ b/src/vault/core.clj @@ -146,7 +146,7 @@ - `path`: `String`, the path in vault of the secret you wish to list secrets at") (read-secret - [client path opts merge-req] [client path opts] + [client path opts] "Reads a resource from a path. Returns the full map of stored data if the resource exists, or throws an exception if not. @@ -165,7 +165,9 @@ Whether or not to rotate this secret when the lease is near expiry and cannot be renewed. - `:force-read` - Force the secret to be read from the server even if there is a valid lease cached.") + Force the secret to be read from the server even if there is a valid lease cached. + - `:request-opts` + Additional top level opts supported by clj-http") (write-secret! [client path data] diff --git a/src/vault/secrets/kvv2.clj b/src/vault/secrets/kvv2.clj index 109fb8a..795e19b 100644 --- a/src/vault/secrets/kvv2.clj +++ b/src/vault/secrets/kvv2.clj @@ -37,8 +37,7 @@ cannot be renewed. - `:force-read`, `boolean` Force the secret to be read from the server even if there is a valid lease cached. - - `version`:, `nat num`, the version of the secret you wish to read" - + - `:version`, `nat num`, the version of the secret you wish to read" ([client mount path opts] (api-util/supports-not-found opts @@ -46,10 +45,9 @@ (vault/read-secret client (str mount "/data/" path) - (dissoc opts :not-found) - (if (:version opts) - {:query-params {"version" (:version opts)}} - {}))))) + (-> opts + (dissoc :not-found) + (assoc :request-opts (if-let [ver (:version opts)] {:query-params {"version" ver}} {}))))))) ([client mount path] (read-secret client mount path nil))) From a60a5e9c4f96c3528f894c800f21d887e5a534a8 Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Wed, 11 Dec 2019 18:25:06 -0800 Subject: [PATCH 51/53] Fixed typo in README --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 89b64b1..a254495 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,9 @@ Leiningen, add the following dependency to your project definition: :leases #} ; Pull in the secret engine you wish to use: -=> (require '[vault.secrets.kvv1 :as kvv1]) +=> (require '[vault.secrets.kvv1 :as vault-kvv1]) -=> (kvv1/read-secret client "secret/foo/bar") +=> (vault-kvv1/read-secret client "secret/foo/bar") {:data "baz qux"} ``` @@ -61,15 +61,15 @@ secret fixture data. => (def mock-client (vault/new-client "mock:dev/secrets.edn")) ; Pull in the secret engine you wish to use: -=> (require '[vault.secrets.kvv1 :as kvv1]) +=> (require '[vault.secrets.kvv1 :as vault-kvv1]) -=> (vault/read-secret mock-client "secret/service/foo/login") +=> (vault-kvv1/read-secret mock-client "secret/service/foo/login") {:user "foo", :pass "abc123"} ``` ## Secret Engines Vault supports many different [secret engines](https://www.vaultproject.io/docs/secrets/), each with very different -capabilities. For the most part, secrets engine behave similar to virtual filesystems, supporting CRUD operations. +capabilities. For the most part, secret engines behave similar to virtual filesystems, supporting CRUD operations. Secret engines are very flexible, so please check out the [Vault docs](https://www.vaultproject.io/docs/secrets/) for more info. @@ -81,13 +81,13 @@ exposed in `vault.core`** #### [KV V1](https://www.vaultproject.io/docs/secrets/kv/kv-v1.html) ```clojure -(require '[vault.secrets.kvv1 :as kvv1]) +(require '[vault.secrets.kvv1 :as vault-kvv1]) ``` #### [KV V2](https://www.vaultproject.io/docs/secrets/kv/kv-v2.html) ```clojure -(require '[vault.secrets.kvv2 :as kvv2]) +(require '[vault.secrets.kvv2 :as vault-kvv2]) ``` ### Adding your own Secret Engines From 0749c1c817e469f2cecd8e0bb01dbdee73b8f68b Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Thu, 12 Dec 2019 12:06:15 -0800 Subject: [PATCH 52/53] Modified metadata and config in kvv2 -> kebab case - kebabs are tastier than snakes --- src/vault/client/api_util.clj | 20 ++++++++-- src/vault/secrets/kvv2.clj | 45 +++++++++++---------- test/vault/secrets/kvv2_test.clj | 67 ++++++++++++++++++++------------ 3 files changed, 80 insertions(+), 52 deletions(-) diff --git a/src/vault/client/api_util.clj b/src/vault/client/api_util.clj index c3fb353..3bbbf47 100644 --- a/src/vault/client/api_util.clj +++ b/src/vault/client/api_util.clj @@ -30,11 +30,11 @@ (throw ex#)))))) -(defn ^:no-doc kebabify-keys +(defn- ^:no-doc keyword-swap-chars "Rewrites keyword map keys with underscores changed to dashes." - [value] - (let [kebab-kw #(-> % name (str/replace "_" "-") keyword) - xf-entry (juxt (comp kebab-kw key) val)] + [value find replace] + (let [replace-kw #(-> % name (str/replace find replace) keyword) + xf-entry (juxt (comp replace-kw key) val)] (walk/postwalk (fn xf-maps [x] @@ -44,6 +44,18 @@ value))) +(defn ^:no-doc kebabify-keys + "Rewrites keyword map keys with underscores changed to dashes." + [value] + (keyword-swap-chars value "_" "-")) + + +(defn ^:no-doc snakeify-keys + "Rewrites keyword map keys with dashes changed to underscores." + [value] + (keyword-swap-chars value "-" "_")) + + (defn ^:no-doc sha-256 "Geerate a SHA-2 256 bit digest from a string." [s] diff --git a/src/vault/secrets/kvv2.clj b/src/vault/secrets/kvv2.clj index 795e19b..49e22c5 100644 --- a/src/vault/secrets/kvv2.clj +++ b/src/vault/secrets/kvv2.clj @@ -61,7 +61,7 @@ - `path`: `String`, the path in vault of the secret you wish to read metadata for - `opts`: `map`, options to affect the read call, see `vault.core/read-secret` for more details" ([client mount path opts] - (vault/read-secret client (str mount "/metadata/" path) opts)) + (api-util/kebabify-keys (vault/read-secret client (str mount "/metadata/" path) opts))) ([client mount path] (read-metadata client mount path nil))) @@ -78,14 +78,14 @@ - `metadata`: `map` the metadata you wish to write. Metadata options are: - -`:max_versions`: `int`, The number of versions to keep per key. This value applies to all keys, but a key's + -`:max-versions`: `int`, The number of versions to keep per key. This value applies to all keys, but a key's metadata setting can overwrite this value. Once a key has more than the configured allowed versions the oldest version will be permanently deleted. Defaults to 10. - -`:cas_required`: `boolean`, – If true all keys will require the cas parameter to be set on all write requests. - - :delete_version_after` `String` – If set, specifies the length of time before a version is deleted. + -`:cas-required`: `boolean`, – If true all keys will require the cas parameter to be set on all write requests. + - :delete-version-after` `String` – If set, specifies the length of time before a version is deleted. Accepts Go duration format string." [client mount path metadata] - (vault/write-secret! client (str mount "/metadata/" path) metadata)) + (vault/write-secret! client (str mount "/metadata/" path) (api-util/snakeify-keys metadata))) (defn write-secret! @@ -113,15 +113,14 @@ - `config`: `map`, the configurations you wish to write. Configuration options are: - -`:max_versions`: `int`, The number of versions to keep per key. This value applies to all keys, but a key's + - `:max-versions`: `int`, The number of versions to keep per key. This value applies to all keys, but a key's metadata setting can overwrite this value. Once a key has more than the configured allowed versions the oldest version will be permanently deleted. Defaults to 10. - - `:cas_required`: `boolean`, – If true all keys will require the cas parameter to be set on all write requests. - - `:delete_version_after` `String` – If set, specifies the length of time before a version is deleted. + - `:cas-required`: `boolean`, – If true all keys will require the cas parameter to be set on all write requests. + - `:delete-version-after` `String` – If set, specifies the length of time before a version is deleted. Accepts Go duration format string." - [client mount config] - (vault/write-secret! client (str mount "/config") config)) + (vault/write-secret! client (str mount "/config") (api-util/snakeify-keys config))) (defn read-config @@ -132,33 +131,33 @@ - `mount`: `String`, the path in vault of the secret engine you wish to read configurations for - `opts`: `map`, options to affect the read call, see `vault.core/read-secret` for more details" ([client mount opts] - (vault/read-secret client (str mount "/config") opts)) + (api-util/kebabify-keys (vault/read-secret client (str mount "/config") opts))) ([client mount] (read-config client mount nil))) (defn destroy-secret! "Permanently removes the specified version data for the provided key and version numbers from the key-value store. -Returns a boolean indicating whether the destroy was successful. + Returns a boolean indicating whether the destroy was successful. -Params: -- `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops -- `mount`: `String`, the path in vault of the secret engine you wish to configure -- `path`: `String`, the path aligned to the secret you wish to destroy -- `versions`: `vector`, the versions you want to destroy" + Params: + - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops + - `mount`: `String`, the path in vault of the secret engine you wish to configure + - `path`: `String`, the path aligned to the secret you wish to destroy + - `versions`: `vector`, the versions you want to destroy" [client mount path versions] (vault/write-secret! client (str mount "/destroy/" path) {:versions versions})) (defn undelete-secret! "Undeletes the data for the provided version and path in the key-value store. This restores the data, allowing it to -be returned on get requests. + be returned on get requests. -Params: -- `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops -- `mount`: `String`, the path in vault of the secret engine you wish to configure -- `path`: `String`, the path aligned to the secret you wish to undelete -- `versions`: `vector`, the versions you want to undelete" + Params: + - `client`: `vault.client`, A client that handles vault auth, leases, and basic CRUD ops + - `mount`: `String`, the path in vault of the secret engine you wish to configure + - `path`: `String`, the path aligned to the secret you wish to undelete + - `versions`: `vector`, the versions you want to undelete" [client mount path versions] (vault/write-secret! client (str mount "/undelete/" path) {:versions versions})) diff --git a/test/vault/secrets/kvv2_test.clj b/test/vault/secrets/kvv2_test.clj index 53aa6d8..bf58324 100644 --- a/test/vault/secrets/kvv2_test.clj +++ b/test/vault/secrets/kvv2_test.clj @@ -19,7 +19,7 @@ response {:auth nil :data {:keys ["foo" "foo/"]} :lease_duration 2764800 - :lease-id "" + :lease_id "" :renewable false}] (vault/authenticate! client :token token-passed-in) (testing "List secrets has correct response and sends correct request" @@ -40,9 +40,12 @@ token-passed-in "fake-token" vault-url "https://vault.example.amperity.com" client (http-client/http-client vault-url) - new-config {:max_versions 5 - :cas_required false - :delete_version_after "3h25m19s"}] + new-config-kebab {:max-versions 5 + :cas-required false + :delete-version-after "3h25m19s"} + new-config-snake {:max_versions 5 + :cas_required false + :delete_version_after "3h25m19s"}] (vault/authenticate! client :token token-passed-in) (testing "Write config sends correct request and returns true on valid call" (with-redefs @@ -51,15 +54,15 @@ (is (= :post (:method req))) (is (= (str vault-url "/v1/" mount "/config") (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) - (is (= new-config (:form-params req))) + (is (= new-config-snake (:form-params req))) {:status 204})] - (is (true? (vault-kvv2/write-config! client mount new-config))))))) + (is (true? (vault-kvv2/write-config! client mount new-config-kebab))))))) (deftest read-config-test - (let [config {:max_versions 5 - :cas_required false - :delete_version_after "3h25m19s"} + (let [config {:max-versions 5 + :cas-required false + :delete-version-after "3h25m19s"} mount "mount" token-passed-in "fake-token" vault-url "https://vault.example.amperity.com" @@ -72,7 +75,7 @@ (is (= :get (:method req))) (is (= (str vault-url "/v1/" mount "/config") (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) - {:body {:data config}})] + {:body {:data (api-util/snakeify-keys config)}})] (is (= config (vault-kvv2/read-config client mount))))))) @@ -243,6 +246,20 @@ :3 {:created_time "2018-03-22T02:36:43.986212308Z" :deletion_time "" :destroyed false}}}} + kebab-metadata {:created-time "2018-03-22T02:24:06.945319214Z" + :current-version 3 + :max-versions 0 + :oldest-version 0 + :updated-time "2018-03-22T02:36:43.986212308Z" + :versions {:1 {:created-time "2018-03-22T02:24:06.945319214Z" + :deletion-time "" + :destroyed false} + :2 {:created-time "2018-03-22T02:36:33.954880664Z" + :deletion-time "" + :destroyed false} + :3 {:created-time "2018-03-22T02:36:43.986212308Z" + :deletion-time "" + :destroyed false}}} mount "mount" path-passed-in "path/passed/in" token-passed-in "fake-token" @@ -258,7 +275,7 @@ (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) {:body data :status 200})] - (is (= (:data data) (vault-kvv2/read-metadata client mount path-passed-in))))) + (is (= kebab-metadata (vault-kvv2/read-metadata client mount path-passed-in))))) (testing "Sends correct request and responds correctly when metadata not found" (with-redefs [clj-http.client/request @@ -273,9 +290,9 @@ (deftest write-metadata-test - (let [payload {:max_versions 5, - :cas_required false, - :delete_version_after "3h25m19s"} + (let [payload {:max-versions 5, + :cas-required false, + :delete-version-after "3h25m19s"} mount "mount" path-passed-in "path/passed/in" token-passed-in "fake-token" @@ -289,7 +306,7 @@ (is (= :post (:method req))) (is (= (str vault-url "/v1/" mount "/metadata/" path-passed-in) (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) - (is (= payload (:form-params req))) + (is (= (api-util/snakeify-keys payload) (:form-params req))) {:status 204})] (is (true? (vault-kvv2/write-metadata! client mount path-passed-in payload))))) (testing "Write metadata sends correct request and responds with false upon failure" @@ -299,7 +316,7 @@ (is (= :post (:method req))) (is (= (str vault-url "/v1/" mount "/metadata/" path-passed-in) (:url req))) (is (= token-passed-in (get (:headers req) "X-Vault-Token"))) - (is (= payload (:form-params req))) + (is (= (api-util/snakeify-keys payload) (:form-params req))) {:status 500})] (is (false? (vault-kvv2/write-metadata! client mount path-passed-in payload))))))) @@ -426,8 +443,8 @@ (testing "Mock client can write and read config" (let [client (mock-client-kvv2) config {:max-versions 5 - :cas_required false - :delete_version_after "3h23m19s"}] + :cas-required false + :delete-version-after "3h23m19s"}] (is (thrown? ExceptionInfo (vault-kvv2/read-config client "mount"))) (is (true? (vault-kvv2/write-config! client "mount" config))) @@ -436,13 +453,13 @@ (let [client (mock-client-kvv2)] (is (thrown? ExceptionInfo (vault-kvv2/read-metadata client "mount" "doesn't exist" {:force-read true}))) - (is (= {:created_time "2018-03-22T02:24:06.945319214Z" - :current_version 1 - :max_versions 0 - :oldest_version 0 - :updated_time "2018-03-22T02:36:43.986212308Z" - :versions {:1 {:created_time "2018-03-22T02:24:06.945319214Z" - :deletion_time "" + (is (= {:created-time "2018-03-22T02:24:06.945319214Z" + :current-version 1 + :max-versions 0 + :oldest-version 0 + :updated-time "2018-03-22T02:36:43.986212308Z" + :versions {:1 {:created-time "2018-03-22T02:24:06.945319214Z" + :deletion-time "" :destroyed false}}} (vault-kvv2/read-metadata client "mount" "identities" {:force-read true}))) (is (true? (vault-kvv2/delete-metadata! client "mount" "identities"))) From 52a6d5dc1702448b77633540490352ae023f144a Mon Sep 17 00:00:00 2001 From: Daniel Rassaby Date: Fri, 13 Dec 2019 09:56:51 -0800 Subject: [PATCH 53/53] Prep 1.0.0 release --- CHANGELOG.md | 13 +++++++++---- project.clj | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7607cf1..ed7e0fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,13 @@ All notable changes to this project will be documented in this file. This change log follows the conventions of [keepachangelog.com](http://keepachangelog.com/). ## [Unreleased] -- Large internal refactors that may result in unexpected behavior +... + +## [1.0.0] - 2019-12-13 + +**THIS RELEASE CONTAINS SOME BREAKING CHANGES!** + +- Large internal refactor that may result in unexpected behavior [#35](https://github.com/amperity/vault-clj/pull/35) - Added support for the KV V2 API [#33](https://github.com/amperity/vault-clj/issues/33) @@ -15,8 +21,6 @@ This change log follows the conventions of [keepachangelog.com](http://keepachan - Bugfix for mocking delete [#35](https://github.com/amperity/vault-clj/pull/35) -... - ## [0.7.1] - 2019-11-20 - Bugfix in mock client so that it acts more similarly to http client when creating tokens (create-token!) @@ -242,7 +246,8 @@ With this version, the project has been forked to the Amperity organization. ### Added - Initial library implementation. -[Unreleased]: https://github.com/amperity/vault-clj/compare/0.7.1...HEAD +[Unreleased]: https://github.com/amperity/vault-clj/compare/1.0.0...HEAD +[1.0.0]: https://github.com/amperity/vault-clj/compare/0.7.1...1.0.0 [0.7.1]: https://github.com/amperity/vault-clj/compare/0.7.0...0.7.1 [0.7.0]: https://github.com/amperity/vault-clj/compare/0.6.6...0.7.0 [0.6.6]: https://github.com/amperity/vault-clj/compare/0.6.5...0.6.6 diff --git a/project.clj b/project.clj index 9da4b90..e746ab0 100644 --- a/project.clj +++ b/project.clj @@ -1,4 +1,4 @@ -(defproject amperity/vault-clj "0.7.2-SNAPSHOT" +(defproject amperity/vault-clj "1.0.0" :description "Clojure client for the Vault secret management system." :url "https://github.com/amperity/vault-clj" :license {:name "Apache License"