├── CODEOWNERS ├── bin ├── repl ├── test ├── build └── server ├── tests.edn ├── .cljstyle ├── .gitignore ├── dev ├── env.sh └── vault │ └── repl.clj ├── doc ├── cljdoc.edn ├── pom.xml ├── development.md ├── control-flow.md └── upgrading-1x.md ├── test ├── vault │ ├── auth │ │ ├── github_test.clj │ │ ├── kubernetes_test.clj │ │ ├── userpass_test.clj │ │ ├── ldap_test.clj │ │ ├── approle_test.clj │ │ └── token_test.clj │ ├── sys │ │ ├── health_test.clj │ │ ├── wrapping_test.clj │ │ ├── mounts_test.clj │ │ └── auth_test.clj │ ├── secret │ │ ├── database_test.clj │ │ ├── transit_test.clj │ │ └── kv │ │ │ └── v1_test.clj │ ├── client │ │ ├── mock_test.clj │ │ ├── flow_test.clj │ │ └── http_test.clj │ ├── util_test.clj │ └── integration.clj └── dialog.edn ├── docker ├── docker-compose.yml └── ldap │ ├── sample-organization.ldif │ ├── setup.sh │ └── README.md ├── LICENSE ├── .clj-kondo └── config.edn ├── src └── vault │ ├── client │ ├── proto.clj │ ├── flow.cljc │ └── mock.clj │ ├── sys │ ├── health.clj │ ├── leases.clj │ ├── wrapping.clj │ ├── mounts.clj │ └── auth.clj │ ├── auth │ ├── github.clj │ ├── ldap.clj │ ├── userpass.clj │ ├── kubernetes.clj │ ├── token.clj │ └── approle.clj │ ├── auth.clj │ ├── secret │ ├── database.clj │ ├── aws.clj │ ├── kv │ │ └── v1.clj │ └── transit.clj │ ├── util.cljc │ ├── client.cljc │ └── lease.cljc ├── bb.edn ├── deps.edn ├── .circleci └── config.yml └── README.md /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @greglook @pyncc 2 | -------------------------------------------------------------------------------- /bin/repl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # vim: ft=bash 3 | 4 | cd "$(dirname "${BASH_SOURCE[0]}")/.." 5 | 6 | exec clj -M:dev:repl 7 | -------------------------------------------------------------------------------- /tests.edn: -------------------------------------------------------------------------------- 1 | #kaocha/v1 2 | {:tests [{:id :unit 3 | :kaocha.filter/skip-meta [:integration :service-required]} 4 | {:id :integration 5 | :kaocha.filter/focus-meta [:integration]}]} 6 | -------------------------------------------------------------------------------- /.cljstyle: -------------------------------------------------------------------------------- 1 | ;; Clojure formatting rules 2 | ;; vim: filetype=clojure 3 | {:files 4 | {:ignore #{"checkouts" "target"}} 5 | 6 | :rules 7 | {:indentation 8 | {:indents 9 | {with-now [[:block 1]]}}}} 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Development Tools 2 | /Library 3 | .calva 4 | .clj-kondo/.cache 5 | .cpcache 6 | .idea/ 7 | *.iml 8 | .lsp 9 | .nrepl-port 10 | 11 | # Build Artifacts 12 | /target 13 | *.asc 14 | *.class 15 | *.jar 16 | -------------------------------------------------------------------------------- /dev/env.sh: -------------------------------------------------------------------------------- 1 | # A convenience file which can be sourced to set connection variables for the 2 | # `vault` CLI to connect to the server started by the `dev/server` script. 3 | 4 | export VAULT_ADDR="http://127.0.0.1:8200" 5 | export VAULT_TOKEN="t0p-53cr3t" 6 | -------------------------------------------------------------------------------- /doc/cljdoc.edn: -------------------------------------------------------------------------------- 1 | {:cljdoc/languages ["clj"] 2 | :cljdoc.doc/tree 3 | [["Readme" {:file "README.md"}] 4 | ["Control Flow" {:file "doc/control-flow.md"}] 5 | ["Local Development" {:file "doc/development.md"}] 6 | ["Upgrading from 1.x" {:file "doc/upgrading-1x.md"}] 7 | ["Changes" {:file "CHANGELOG.md"}]]} 8 | -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # vim: ft=bash 3 | 4 | cd "$(dirname "${BASH_SOURCE[0]}")/.." 5 | 6 | if [[ $1 = check ]]; then 7 | exec clojure -M:check 8 | elif [[ $1 = coverage ]]; then 9 | shift 10 | exec clojure -M:dev:coverage "$@" 11 | else 12 | exec clojure -M:dev:test "$@" 13 | fi 14 | -------------------------------------------------------------------------------- /test/vault/auth/github_test.clj: -------------------------------------------------------------------------------- 1 | (ns vault.auth.github-test 2 | (:require 3 | [clojure.test :refer [deftest is testing]] 4 | [vault.auth.github :as github] 5 | [vault.client.http :as http])) 6 | 7 | 8 | (deftest with-mount 9 | (testing "different mounts" 10 | (let [client (http/http-client "https://foo.com")] 11 | (is (nil? (::github/mount client))) 12 | (is (= "test-mount" (::github/mount (github/with-mount client "test-mount"))))))) 13 | -------------------------------------------------------------------------------- /test/vault/auth/kubernetes_test.clj: -------------------------------------------------------------------------------- 1 | (ns vault.auth.kubernetes-test 2 | (:require 3 | [clojure.test :refer [deftest is testing]] 4 | [vault.auth.kubernetes :as k8s] 5 | [vault.client.http :as http])) 6 | 7 | 8 | (deftest with-mount 9 | (testing "different mounts" 10 | (let [client (http/http-client "https://foo.com")] 11 | (is (nil? (::k8s/mount client))) 12 | (is (= "test-mount" (::k8s/mount (k8s/with-mount client "test-mount"))))))) 13 | -------------------------------------------------------------------------------- /test/dialog.edn: -------------------------------------------------------------------------------- 1 | {:level :warn 2 | 3 | :levels 4 | {"vault" #profile {:repl :debug 5 | :test :trace 6 | :default :info}} 7 | 8 | :outputs 9 | #profile {:test {:nop :null} 10 | :repl {:console {:type :print 11 | :format :pretty 12 | :timestamp :short}} 13 | :default {:stdout {:type :print 14 | :stream :stdout 15 | :format :simple}}}} 16 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | openldap: 4 | image: osixia/openldap:1.4.0 5 | container_name: vault-openldap 6 | environment: 7 | - LDAP_ORGANISATION=vault-clj 8 | - LDAP_DOMAIN=test.com 9 | - LDAP_ADMIN_PASSWORD=ldap-admin 10 | ports: 11 | - '389:389' 12 | 13 | postgres: 14 | image: postgres:11-alpine 15 | container_name: vault-postgres 16 | environment: 17 | - POSTGRES_USER=postgres 18 | - POSTGRES_PASSWORD=hunter2 19 | ports: 20 | - "5432:5432" 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Licensed under the Apache License, Version 2.0 (the "License"); 2 | you may not use this file except in compliance with the License. 3 | You may obtain a copy of the License at 4 | 5 | http://www.apache.org/licenses/LICENSE-2.0 6 | 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and 11 | limitations under the License. 12 | -------------------------------------------------------------------------------- /docker/ldap/sample-organization.ldif: -------------------------------------------------------------------------------- 1 | dn: ou=groups,dc=test,dc=com 2 | objectClass: organizationalunit 3 | objectClass: top 4 | ou: groups 5 | description: groups of users 6 | 7 | dn: ou=users,dc=test,dc=com 8 | objectClass: organizationalunit 9 | objectClass: top 10 | ou: users 11 | description: users 12 | 13 | dn: cn=dev,ou=groups,dc=test,dc=com 14 | objectClass: groupofnames 15 | objectClass: top 16 | description: testing group for dev 17 | cn: dev 18 | member: cn=alice,ou=users,dc=test,dc=com 19 | 20 | dn: cn=alice,ou=users,dc=test,dc=com 21 | objectClass: person 22 | objectClass: top 23 | cn: alice 24 | sn: andbob 25 | memberOf: cn=dev,ou=groups,dc=test,dc=com 26 | userPassword: hunter2 27 | -------------------------------------------------------------------------------- /.clj-kondo/config.edn: -------------------------------------------------------------------------------- 1 | {:linters 2 | {:consistent-alias 3 | {:level :warning 4 | :aliases {clojure.java.io io 5 | clojure.set set 6 | clojure.string str 7 | clojure.tools.logging log 8 | vault.auth auth 9 | vault.client vault 10 | vault.client.flow f 11 | vault.client.proto proto 12 | vault.lease lease 13 | vault.util u}} 14 | 15 | :unresolved-symbol 16 | {:exclude [(vault.integration/with-dev-server [client])]}} 17 | 18 | ;; Can only lint clj because kondo doesn't understand the :bb platform 19 | ;; https://github.com/clj-kondo/clj-kondo/issues/1154 20 | :cljc 21 | {:features #{:clj}}} 22 | -------------------------------------------------------------------------------- /docker/ldap/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Prerequisite: start the local Vault server with `../dev/server` 3 | # Steps cribbed and adapted from a mixture of: 4 | # - https://learn.hashicorp.com/tutorials/vault/openldap 5 | # - https://www.vaultproject.io/docs/auth/ldap#configuration 6 | 7 | ldapadd -cxD "cn=admin,dc=test,dc=com" -f sample-organization.ldif -w ldap-admin 8 | 9 | export OPENLDAP_URL=127.0.0.1:389 10 | 11 | source ../../dev/env.sh 12 | 13 | vault auth enable ldap 14 | 15 | vault write auth/ldap/config \ 16 | url="ldap://$OPENLDAP_URL" \ 17 | userdn="ou=users,dc=test,dc=com" \ 18 | groupdn="ou=groups,dc=test,dc=com" \ 19 | binddn="cn=admin,dc=test,dc=com" \ 20 | bindpass="ldap-admin" 21 | -------------------------------------------------------------------------------- /src/vault/client/proto.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc vault.client.proto 2 | "Core Vault client protocol.") 3 | 4 | 5 | (defprotocol Client 6 | "Marker protocol that indicates an object is a valid Vault client interface." 7 | 8 | (auth-info 9 | [client] 10 | "Return the client's current auth information, a map containing the 11 | `:vault.auth/token` and other metadata keys from the `vault.auth` 12 | namespace. Returns nil if the client is unauthenticated.") 13 | 14 | (authenticate! 15 | [client auth-info] 16 | "Manually authenticate the client by providing a map of auth information 17 | containing a `:vault.auth/token`. As a shorthand, a Vault token string may 18 | be provided directly. Returns the client.")) 19 | -------------------------------------------------------------------------------- /doc/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | vault-clj 5 | Clojure client interface for Hashicorp Vault 6 | https://github.com/amperity/vault-clj 7 | 8 | 9 | Apache License 2.0 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | 13 | 14 | https://github.com/amperity/vault-clj 15 | scm:git:git://github.com/amperity/vault-clj.git 16 | scm:git:ssh://git@github.com/amperity/vault-clj.git 17 | 18 | 19 | -------------------------------------------------------------------------------- /bin/build: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # vim: ft=bash 3 | 4 | cd "$(dirname "${BASH_SOURCE[0]}")/.." 5 | 6 | if [[ $1 = clean ]]; then 7 | rm -rf target 8 | elif [[ $1 = hiera ]]; then 9 | shift 10 | exec clojure -X:hiera graph "$@" 11 | elif [[ $1 = deploy ]]; then 12 | shift 13 | if [[ -f ~/.clojure/clojars.env ]]; then 14 | source ~/.clojure/clojars.env 15 | fi 16 | if [[ -z $CLOJARS_USERNAME ]]; then 17 | read -p "Clojars username: " CLOJARS_USERNAME 18 | if [[ -z $CLOJARS_USERNAME ]]; then 19 | echo "No username available, aborting" >&2 20 | exit 1 21 | fi 22 | fi 23 | if [[ -z $CLOJARS_PASSWORD ]]; then 24 | read -p "Clojars deploy token: " CLOJARS_PASSWORD 25 | if [[ -z $CLOJARS_PASSWORD ]]; then 26 | echo "No deploy token available, aborting" >&2 27 | exit 1 28 | fi 29 | fi 30 | export CLOJARS_USERNAME CLOJARS_PASSWORD 31 | exec clojure -T:build deploy "$@" 32 | else 33 | exec clojure -T:build "$@" 34 | fi 35 | -------------------------------------------------------------------------------- /test/vault/sys/health_test.clj: -------------------------------------------------------------------------------- 1 | (ns vault.sys.health-test 2 | (:require 3 | [clojure.string :as str] 4 | [clojure.test :refer [is testing deftest]] 5 | [vault.client.mock :refer [mock-client]] 6 | [vault.integration :refer [with-dev-server]] 7 | [vault.sys.health :as sys.health])) 8 | 9 | 10 | (deftest mock-api 11 | (let [client (mock-client) 12 | status (sys.health/read-health client {})] 13 | (is (str/starts-with? (:cluster-name status) "vault-cluster-")) 14 | (is (true? (:initialized status))) 15 | (is (false? (:standby status))) 16 | (is (false? (:sealed status))) 17 | (is (pos-int? (:server-time-utc status))) 18 | (is (string? (:version status))))) 19 | 20 | 21 | (deftest ^:integration http-api 22 | (with-dev-server 23 | (testing "read-health" 24 | (let [status (sys.health/read-health client {})] 25 | (is (str/starts-with? (:cluster-name status) "vault-cluster-")) 26 | (is (true? (:initialized status))) 27 | (is (false? (:standby status))) 28 | (is (false? (:sealed status))) 29 | (is (pos-int? (:server-time-utc status))) 30 | (is (string? (:version status))))))) 31 | -------------------------------------------------------------------------------- /bb.edn: -------------------------------------------------------------------------------- 1 | ;; Type bb tasks to see all tasks 2 | ;; Type bb or bb run to run a task 3 | {:min-bb-version "0.5.1" 4 | :paths ["script"] 5 | :deps {amperity/vault-clj {:local/root "."}} 6 | :tasks {:requires ([vault.client :as vault] 7 | [vault.auth.github :as v.github] 8 | [vault.secret.kv.v2 :as v.kv]) 9 | :init (let [vault-addr (or (System/getenv "VAULT_ADDR") "http://localhost:8200") 10 | auth-method (or (keyword (System/getenv "VAULT_AUTH")) :token) 11 | auth-token (System/getenv "VAULT_TOKEN")] 12 | (def vault-client 13 | (vault/new-client vault-addr)) 14 | (case auth-method 15 | :token 16 | (vault/authenticate! vault-client auth-token) 17 | 18 | :github 19 | (v.github/login vault-client auth-token))) 20 | ;; Helpers 21 | vault-get {:doc "Get dev secrets from vault using github auth. 22 | export VAULT_ADDR=\"https://...\" 23 | export VAULT_AUTH='token' or 'github' 24 | export VAULT_TOKEN=login token or github-personal-token" 25 | :task (println (v.kv/read-secret vault-client "foo/bar/baz"))}}} 26 | -------------------------------------------------------------------------------- /src/vault/sys/health.clj: -------------------------------------------------------------------------------- 1 | (ns vault.sys.health 2 | "The `/sys/health` endpoint is used to check the health status of Vault. 3 | 4 | Reference: https://www.vaultproject.io/api-docs/system/health" 5 | (:require 6 | [vault.client.http :as http] 7 | [vault.client.mock :as mock] 8 | [vault.util :as u]) 9 | (:import 10 | vault.client.http.HTTPClient 11 | vault.client.mock.MockClient)) 12 | 13 | 14 | ;; ## API Protocol 15 | 16 | (defprotocol API 17 | "Methods for checking the health of Vault." 18 | 19 | (read-health 20 | [client params] 21 | "Return the health status of Vault.")) 22 | 23 | 24 | ;; ## Mock Client 25 | 26 | (extend-type MockClient 27 | 28 | API 29 | 30 | (read-health 31 | [client _params] 32 | (mock/success-response 33 | client 34 | {:cluster-id "01234567-89ab-cdef-0123-456789abcdef" 35 | :cluster-name "vault-cluster-mock" 36 | :version "0.0.0" 37 | :initialized true 38 | :sealed false 39 | :standby false 40 | :performance-standby false 41 | :replication-perf-mode "disabled" 42 | :replication-dr-mode "disabled" 43 | :server-time-utc (u/now-milli)}))) 44 | 45 | 46 | ;; ## HTTP Client 47 | 48 | (extend-type HTTPClient 49 | 50 | API 51 | 52 | (read-health 53 | [client params] 54 | (http/call-api 55 | client ::read-health 56 | :get "sys/health" 57 | {:query-params params}))) 58 | -------------------------------------------------------------------------------- /dev/vault/repl.clj: -------------------------------------------------------------------------------- 1 | (ns vault.repl 2 | (:require 3 | [clojure.java.io :as io] 4 | [clojure.repl :refer :all] 5 | [clojure.stacktrace :refer [print-cause-trace]] 6 | [clojure.string :as str] 7 | [clojure.tools.namespace.repl :refer [refresh]] 8 | [vault.auth :as auth] 9 | [vault.auth.approle :as auth.approle] 10 | [vault.auth.token :as auth.token] 11 | [vault.auth.userpass :as auth.userpass] 12 | [vault.client :as vault] 13 | [vault.client.flow :as f] 14 | [vault.client.http :as http] 15 | [vault.client.mock :as mock] 16 | [vault.lease :as lease] 17 | [vault.secret.aws :as aws] 18 | [vault.secret.database :as database] 19 | [vault.secret.kv.v1 :as kv1] 20 | [vault.secret.kv.v2 :as kv2] 21 | [vault.secret.transit :as transit] 22 | [vault.sys.auth :as sys.auth] 23 | [vault.sys.health :as sys.health] 24 | [vault.util :as u])) 25 | 26 | 27 | (def client nil) 28 | 29 | 30 | (defn stop-client 31 | "Stop the running client, if any." 32 | [] 33 | (when client 34 | (alter-var-root #'client vault/stop))) 35 | 36 | 37 | (defn init-client 38 | "Initialize a new client, stopping the running one if present." 39 | [] 40 | (stop-client) 41 | (let [vault-addr "http://127.0.0.1:8200" 42 | vault-token "t0p-53cr3t" 43 | vault-client (http/http-client vault-addr)] 44 | (when vault-token 45 | (vault/authenticate! vault-client vault-token)) 46 | (alter-var-root #'client (constantly (vault/start vault-client)))) 47 | :init) 48 | 49 | 50 | (defn reset 51 | "Reload any changed code, and initialize a new client." 52 | [] 53 | (stop-client) 54 | (refresh :after 'vault.repl/init-client)) 55 | -------------------------------------------------------------------------------- /doc/development.md: -------------------------------------------------------------------------------- 1 | # Local Development 2 | 3 | `vault-clj` uses Clojure's CLI tooling, `deps.edn`, and `tools.build` for 4 | development. 5 | 6 | 7 | ## Server 8 | 9 | To run a local development Vault server, use the bin script: 10 | 11 | ```bash 12 | bin/server 13 | ``` 14 | 15 | This starts Vault with a fixed root token. You can source the `dev/env.sh` 16 | script to set the relevant connection variables for using the `vault` CLI 17 | locally. 18 | 19 | 20 | ## REPL 21 | 22 | To start a basic REPL, use the bin script: 23 | 24 | ```bash 25 | bin/repl 26 | ``` 27 | 28 | 29 | ## Run Tests 30 | 31 | To test-compile the code and find any reflection warnings: 32 | 33 | ```bash 34 | bin/test check 35 | ``` 36 | 37 | Tests are run with [kaocha](https://github.com/lambdaisland/kaocha) via a bin script: 38 | 39 | ```bash 40 | # run tests once 41 | bin/test unit 42 | 43 | # watch and rerun tests 44 | bin/test unit --watch 45 | 46 | # run integration tests 47 | bin/test integration 48 | ``` 49 | 50 | To compute test coverage with [cloverage](https://github.com/cloverage/cloverage): 51 | 52 | ```bash 53 | bin/test coverage 54 | ``` 55 | 56 | 57 | ## Build Jar 58 | 59 | For compiling code and building a JAR file, dialog uses `tools.build`. The 60 | various commands can be found in the [`build.clj`](../build.clj) file and 61 | invoked with the `-T:build` alias or the bin script: 62 | 63 | ```bash 64 | # clean artifacts 65 | bin/build clean 66 | 67 | # generate a namespace graph 68 | bin/build hiera 69 | 70 | # create a jar 71 | bin/build jar 72 | 73 | # install to local repo 74 | bin/build install 75 | 76 | # prepare the next release 77 | bin/build prep-release 78 | 79 | # deploy to Clojars 80 | bin/build deploy 81 | ``` 82 | -------------------------------------------------------------------------------- /test/vault/auth/userpass_test.clj: -------------------------------------------------------------------------------- 1 | (ns vault.auth.userpass-test 2 | (:require 3 | [clojure.test :refer [deftest is testing]] 4 | [vault.auth :as auth] 5 | [vault.auth.userpass :as userpass] 6 | [vault.client :as vault] 7 | [vault.integration :refer [with-dev-server cli]])) 8 | 9 | 10 | (defn- assert-authenticated-map 11 | [auth] 12 | (is (string? (:accessor auth))) 13 | (is (string? (:client-token auth))) 14 | (is (pos-int? (:lease-duration auth)))) 15 | 16 | 17 | (deftest ^:integration http-api 18 | (with-dev-server 19 | (testing "login" 20 | (testing "with default mount" 21 | (cli "auth" "enable" "userpass") 22 | (cli "write" "auth/userpass/users/foo" "password=bar") 23 | (let [original-auth-info (vault/auth-info client) 24 | response (userpass/login client "foo" "bar") 25 | auth-info (vault/auth-info client)] 26 | (is (nil? (::userpass/mount client))) 27 | (assert-authenticated-map response) 28 | (is (= (:client-token response) 29 | (::auth/token auth-info))) 30 | (is (not= (::auth/token original-auth-info) 31 | (::auth/token auth-info))))) 32 | (testing "with alternate mount" 33 | (cli "auth" "enable" "-path=auth-test" "userpass") 34 | (cli "write" "auth/auth-test/users/baz" "password=qux") 35 | (let [client' (userpass/with-mount client "auth-test") 36 | response (userpass/login client' "baz" "qux")] 37 | (is (= "auth-test" (::userpass/mount client'))) 38 | (assert-authenticated-map response) 39 | (is (= (:client-token response) 40 | (::auth/token (vault/auth-info client'))))))))) 41 | -------------------------------------------------------------------------------- /src/vault/auth/github.clj: -------------------------------------------------------------------------------- 1 | (ns vault.auth.github 2 | "The `/auth/github` endpoint manages GitHub authentication functionality. 3 | 4 | Reference: https://www.vaultproject.io/api-docs/auth/github" 5 | (:require 6 | [vault.client.http :as http] 7 | [vault.client.proto :as proto] 8 | [vault.util :as u]) 9 | (:import 10 | vault.client.http.HTTPClient)) 11 | 12 | 13 | (def default-mount 14 | "Default mount point to use if one is not provided." 15 | "github") 16 | 17 | 18 | (defprotocol API 19 | "The GitHub auth endpoints manage GitHub authentication functionality." 20 | 21 | (with-mount 22 | [client mount] 23 | "Return an updated client which will resolve calls against the provided 24 | mount instead of the default. Passing `nil` will reset the client to the 25 | default.") 26 | 27 | (login 28 | [client token] 29 | "Login using a GitHub access token. This method uses the 30 | `/auth/github/login` endpoint. 31 | 32 | Returns the `auth` map from the login endpoint and updates the auth 33 | information in the client, including the new client token.")) 34 | 35 | 36 | (extend-type HTTPClient 37 | 38 | API 39 | 40 | (with-mount 41 | [client mount] 42 | (if (some? mount) 43 | (assoc client ::mount mount) 44 | (dissoc client ::mount))) 45 | 46 | 47 | (login 48 | [client token] 49 | (let [mount (::mount client default-mount) 50 | api-path (u/join-path "auth" mount "login")] 51 | (http/call-api 52 | client ::login 53 | :post api-path 54 | {:info {::mount mount} 55 | :content-type :json 56 | :body {:token token} 57 | :handle-response u/kebabify-body-auth 58 | :on-success (fn update-auth 59 | [auth] 60 | (proto/authenticate! client auth))})))) 61 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src"] 2 | 3 | :deps 4 | {org.clojure/clojure {:mvn/version "1.11.1"} 5 | org.clojure/data.json {:mvn/version "2.4.0"} 6 | org.clojure/tools.logging {:mvn/version "1.2.4"} 7 | http-kit/http-kit {:mvn/version "2.7.0"}} 8 | 9 | :aliases 10 | {:dev 11 | {:extra-deps {com.amperity/dialog {:mvn/version "2.0.115"}} 12 | :jvm-opts ["-XX:-OmitStackTraceInFastThrow" 13 | "-Dclojure.main.report=stderr"]} 14 | 15 | :repl 16 | {:extra-paths ["dev" "test"] 17 | :extra-deps {org.clojure/tools.namespace {:mvn/version "1.4.4"} 18 | mvxcvi/puget {:mvn/version "1.3.4"}} 19 | :jvm-opts ["-Ddialog.profile=repl"] 20 | :main-opts ["-e" "(require,'puget.printer)" 21 | "-e" "(clojure.main/repl,:init,#(do,(require,'vault.repl),(in-ns,'vault.repl)),:print,puget.printer/cprint)"]} 22 | 23 | :check 24 | {:extra-deps {io.github.athos/clj-check {:git/sha "518d5a1cbfcd7c952f548e6dbfcb9a4a5faf9062"}} 25 | :main-opts ["-m" "clj-check.check"]} 26 | 27 | :test 28 | {:extra-paths ["test"] 29 | :extra-deps {lambdaisland/kaocha {:mvn/version "1.86.1355"}} 30 | :jvm-opts ["-Ddialog.profile=test"] 31 | :main-opts ["-m" "kaocha.runner"]} 32 | 33 | :coverage 34 | {:extra-paths ["test"] 35 | :extra-deps {cloverage/cloverage {:mvn/version "RELEASE"}} 36 | :jvm-opts ["-Ddialog.profile=test"] 37 | :main-opts ["-m" "cloverage.coverage" 38 | "--src-ns-path" "src" 39 | "--test-ns-path" "test"]} 40 | 41 | :hiera 42 | {:deps {io.github.greglook/clj-hiera {:git/tag "2.0.0", :git/sha "b14e514"}} 43 | :exec-fn hiera.main/graph 44 | :exec-args {:cluster-depth 2 45 | :ignore #{vault.util}}} 46 | 47 | :build 48 | {:deps {org.clojure/clojure {:mvn/version "1.11.1"} 49 | org.clojure/tools.build {:mvn/version "0.9.2"} 50 | slipset/deps-deploy {:mvn/version "0.2.1"}} 51 | :ns-default build}}} 52 | -------------------------------------------------------------------------------- /src/vault/auth/ldap.clj: -------------------------------------------------------------------------------- 1 | (ns vault.auth.ldap 2 | "The `/auth/ldap` endpoint manages Lightweight Directory Access Protocol (LDAP) 3 | authentication functionality. 4 | 5 | Reference: https://www.vaultproject.io/api-docs/auth/ldap" 6 | (:require 7 | [vault.client.http :as http] 8 | [vault.client.proto :as proto] 9 | [vault.util :as u]) 10 | (:import 11 | vault.client.http.HTTPClient)) 12 | 13 | 14 | (def default-mount 15 | "Default mount point to use if one is not provided." 16 | "ldap") 17 | 18 | 19 | (defprotocol API 20 | "The LDAP endpoints manage LDAP authentication functionality." 21 | 22 | (with-mount 23 | [client mount] 24 | "Return an updated client which will resolve calls against the provided 25 | mount instead of the default. Passing `nil` will reset the client to the 26 | default.") 27 | 28 | (login 29 | [client username password] 30 | "Login with the LDAP user's username and password. This method uses the 31 | `/auth/ldap/login/:username` endpoint. 32 | 33 | Returns the `auth` map from the login endpoint and updates the auth 34 | information in the client, including the new client token.")) 35 | 36 | 37 | (extend-type HTTPClient 38 | 39 | API 40 | 41 | (with-mount 42 | [client mount] 43 | (if (some? mount) 44 | (assoc client ::mount mount) 45 | (dissoc client ::mount))) 46 | 47 | 48 | (login 49 | [client username password] 50 | (let [mount (::mount client default-mount) 51 | api-path (u/join-path "auth" mount "login" username)] 52 | (http/call-api 53 | client ::login 54 | :post api-path 55 | {:info {::mount mount, ::username username} 56 | :content-type :json 57 | :body {:password password} 58 | :handle-response u/kebabify-body-auth 59 | :on-success (fn update-auth 60 | [auth] 61 | (proto/authenticate! client auth))})))) 62 | -------------------------------------------------------------------------------- /src/vault/auth/userpass.clj: -------------------------------------------------------------------------------- 1 | (ns vault.auth.userpass 2 | "The `/auth/userpass` endpoint manages username & password authentication 3 | functionality. 4 | 5 | Reference: https://www.vaultproject.io/api-docs/auth/userpass" 6 | (:require 7 | [vault.client.http :as http] 8 | [vault.client.proto :as proto] 9 | [vault.util :as u]) 10 | (:import 11 | vault.client.http.HTTPClient)) 12 | 13 | 14 | (def default-mount 15 | "Default mount point to use if one is not provided." 16 | "userpass") 17 | 18 | 19 | (defprotocol API 20 | "The userpass endpoints manage username & password authentication 21 | functionality." 22 | 23 | (with-mount 24 | [client mount] 25 | "Return an updated client which will resolve calls against the provided 26 | mount instead of the default. Passing `nil` will reset the client to the 27 | default.") 28 | 29 | (login 30 | [client username password] 31 | "Login with the username and password. This method uses the 32 | `/auth/userpass/login/:username` endpoint. 33 | 34 | Returns the `auth` map from the login endpoint and updates the auth 35 | information in the client, including the new client token.")) 36 | 37 | 38 | (extend-type HTTPClient 39 | 40 | API 41 | 42 | (with-mount 43 | [client mount] 44 | (if (some? mount) 45 | (assoc client ::mount mount) 46 | (dissoc client ::mount))) 47 | 48 | 49 | (login 50 | [client username password] 51 | (let [mount (::mount client default-mount) 52 | api-path (u/join-path "auth" mount "login" username)] 53 | (http/call-api 54 | client ::login 55 | :post api-path 56 | {:info {::mount mount, ::username username} 57 | :content-type :json 58 | :body {:password password} 59 | :handle-response u/kebabify-body-auth 60 | :on-success (fn update-auth 61 | [auth] 62 | (proto/authenticate! client auth))})))) 63 | -------------------------------------------------------------------------------- /src/vault/auth/kubernetes.clj: -------------------------------------------------------------------------------- 1 | (ns vault.auth.kubernetes 2 | "The `/auth/kubernetes` endpoint manages Kubernetes authentication 3 | functionality. 4 | 5 | Reference: https://www.vaultproject.io/api-docs/auth/kubernetes" 6 | (:require 7 | [vault.client.http :as http] 8 | [vault.client.proto :as proto] 9 | [vault.util :as u]) 10 | (:import 11 | vault.client.http.HTTPClient)) 12 | 13 | 14 | (def default-mount 15 | "Default mount point to use if one is not provided." 16 | "kubernetes") 17 | 18 | 19 | (defprotocol API 20 | "The Kubernetes endpoints manage Kubernetes authentication functionality." 21 | 22 | (with-mount 23 | [client mount] 24 | "Return an updated client which will resolve calls against the provided 25 | mount instead of the default. Passing `nil` will reset the client to the 26 | default.") 27 | 28 | (login 29 | [client role jwt] 30 | "Login to the provided role using a signed JSON Web Token (JWT) for 31 | authenticating a service account. This method uses the 32 | `/auth/kubernetes/login` endpoint. 33 | 34 | Returns the `auth` map from the login endpoint and updates the auth 35 | information in the client, including the new client token.")) 36 | 37 | 38 | (extend-type HTTPClient 39 | 40 | API 41 | 42 | (with-mount 43 | [client mount] 44 | (if (some? mount) 45 | (assoc client ::mount mount) 46 | (dissoc client ::mount))) 47 | 48 | 49 | (login 50 | [client role jwt] 51 | (let [mount (::mount client default-mount) 52 | api-path (u/join-path "auth" mount "login")] 53 | (http/call-api 54 | client ::login 55 | :post api-path 56 | {:info {::mount mount, ::role role} 57 | :content-type :json 58 | :body {:jwt jwt :role role} 59 | :handle-response u/kebabify-body-auth 60 | :on-success (fn update-auth 61 | [auth] 62 | (proto/authenticate! client auth))})))) 63 | -------------------------------------------------------------------------------- /test/vault/auth/ldap_test.clj: -------------------------------------------------------------------------------- 1 | (ns vault.auth.ldap-test 2 | (:require 3 | [clojure.test :refer [deftest is testing]] 4 | [vault.auth :as auth] 5 | [vault.auth.ldap :as ldap] 6 | [vault.client :as vault] 7 | [vault.client.http :as http] 8 | [vault.integration :refer [with-dev-server cli]])) 9 | 10 | 11 | (deftest with-mount 12 | (testing "different mounts" 13 | (let [client (http/http-client "https://foo.com")] 14 | (is (nil? (::ldap/mount client))) 15 | (is (= "test-mount" (::ldap/mount (ldap/with-mount client "test-mount"))))))) 16 | 17 | 18 | (deftest ^:service-required http-api 19 | (let [ldap-url (or (System/getenv "VAULT_LDAP_URL") "localhost:389") 20 | ldap-domain (or (System/getenv "VAULT_LDAP_DOMAIN") "dc=test,dc=com") 21 | admin-pass (System/getenv "VAULT_LDAP_ADMIN_PASS") 22 | login-user (System/getenv "VAULT_LDAP_LOGIN_USER") 23 | login-pass (System/getenv "VAULT_LDAP_LOGIN_PASS")] 24 | (when (and admin-pass login-user login-pass) 25 | (with-dev-server 26 | (cli "auth" "enable" "ldap") 27 | (cli "write" "auth/ldap/config" 28 | (str "url=ldap://" ldap-url) 29 | (str "userdn=ou=users," ldap-domain) 30 | (str "groupdn=ou=groups," ldap-domain) 31 | (str "binddn=cn=admin," ldap-domain) 32 | (str "bindpass=" admin-pass)) 33 | (testing "login" 34 | (reset! (:auth client) {}) 35 | (let [response (ldap/login client login-user login-pass) 36 | auth-info (vault/auth-info client)] 37 | (is (string? (:client-token response))) 38 | (is (string? (:accessor response))) 39 | (is (pos-int? (:lease-duration response))) 40 | (is (true? (:orphan response))) 41 | (is (zero? (:num-uses response))) 42 | (is (= ["default"] (:token-policies response))) 43 | (is (= {:username login-user} (:metadata response))) 44 | (is (= (:client-token response) 45 | (::auth/token auth-info))))))))) 46 | -------------------------------------------------------------------------------- /docker/ldap/README.md: -------------------------------------------------------------------------------- 1 | # Local Lightweight Directory Access Protocol (LDAP) Setup 2 | 3 | This directory contains a simple set of configuration and scripts to allow running an OpenLDAP 4 | server alongside a local Vault server. It is most directly useful for testing the LDAP auth methods 5 | in `vault.auth.ldap`. 6 | 7 | ## Prerequisites 8 | 9 | 1. [Install Docker](https://docs.docker.com/get-docker/) 10 | 11 | ## Running the OpenLDAP and Vault Servers 12 | 13 | 1. Start a local vault server: 14 | ```bash 15 | ../../dev/server 16 | ``` 17 | 18 | 1. Start the OpenLDAP docker container: 19 | ```bash 20 | cd ../ 21 | docker compose up -d openldap 22 | ``` 23 | 24 | 1. Setup the LDAP and Vault configuration: 25 | ```bash 26 | cd ldap 27 | ./setup.sh 28 | ``` 29 | 30 | Now you can login using the Alice user either via the Vault CLI or REPL. 31 | 32 | ### Vault CLI 33 | 34 | ```bash 35 | $ vault login -method=ldap username=alice password=hunter2 36 | Success! You are now authenticated. The token information displayed below 37 | is already stored in the token helper. You do NOT need to run "vault login" 38 | again. Future Vault requests will automatically use this token. 39 | 40 | Key Value 41 | --- ----- 42 | token 43 | token_accessor N5a2eNiNu7QkbGTslMeYe5mp 44 | token_duration 768h 45 | token_renewable true 46 | token_policies ["default"] 47 | identity_policies [] 48 | policies ["default"] 49 | token_meta_username alice 50 | ``` 51 | 52 | ### Clojure REPL 53 | 54 | ```clojure 55 | vault.repl=> (init-client) 56 | :init 57 | 58 | vault.repl=> (require '[vault.auth.ldap :as auth.ldap]) 59 | nil 60 | 61 | vault.repl=> (auth.ldap/login client "alice" "hunter2") 62 | {:accessor "wYOZKBSXeZIVBLLyed2xS4ug", 63 | :client-token "", 64 | :entity-id "615ee160-5373-d6d8-34b3-bf7b11a8b825", 65 | :lease-duration 2764800, 66 | :metadata {:username "alice"}, 67 | :mfa-requirement nil, 68 | :num-uses 0, 69 | :orphan true, 70 | :policies ["default"], 71 | :renewable true, 72 | :token-policies ["default"], 73 | :token-type "service"} 74 | ``` 75 | -------------------------------------------------------------------------------- /test/vault/sys/wrapping_test.clj: -------------------------------------------------------------------------------- 1 | (ns vault.sys.wrapping-test 2 | (:require 3 | [clojure.test :refer [are is testing deftest]] 4 | [vault.integration :refer [with-dev-server]] 5 | [vault.sys.wrapping :as sys.wrapping])) 6 | 7 | 8 | (deftest ^:integration http-api 9 | (with-dev-server 10 | (testing "wrap data" 11 | (testing "integer TTL" 12 | (let [result (sys.wrapping/wrap client {:foo "bar" :baz "buzz"} 30)] 13 | (is (= 30 (:ttl result))) 14 | (is (string? (:token result))))) 15 | (testing "string TTL" 16 | (is (= 60 (:ttl (sys.wrapping/wrap client {:foo "bar"} "60s")))) 17 | (is (= 300 (:ttl (sys.wrapping/wrap client {:foo "bar"} "5m"))))) 18 | (testing "invalid data type" 19 | (are [data] (thrown-with-msg? IllegalArgumentException #"Data to wrap must be a map" 20 | (sys.wrapping/wrap client data 30)) 21 | "hunter2" 22 | [1 2 3]))) 23 | (testing "lookup token" 24 | (let [wrap-info (sys.wrapping/wrap client {:foo "bar"} 60) 25 | result (sys.wrapping/lookup client (:token wrap-info))] 26 | (is (= (:creation-path wrap-info) (:creation-path result))) 27 | (is (= (:creation-time wrap-info) (:creation-time result))) 28 | (is (= (:ttl wrap-info) (:creation-ttl result))))) 29 | (testing "rewrap token" 30 | (let [wrap-info (sys.wrapping/wrap client {:foo "bar"} 60)] 31 | (is (= (:creation-time wrap-info) 32 | (:creation-time (sys.wrapping/lookup client (:token wrap-info))))) 33 | (let [rewrapped-info (sys.wrapping/rewrap client (:token wrap-info)) 34 | rewrapped-lookup (sys.wrapping/lookup client (:token rewrapped-info))] 35 | (is (thrown? Exception (sys.wrapping/lookup client (:token wrap-info))) 36 | "original token should no longer exist") 37 | (is (= (:ttl wrap-info) 38 | (:ttl rewrapped-info) 39 | (:creation-ttl rewrapped-lookup)))))) 40 | (testing "unwrap token" 41 | (let [wrap-info (sys.wrapping/wrap client {:foo "bar" :baz "buzz"} 60) 42 | result (sys.wrapping/unwrap client (:token wrap-info))] 43 | (is (= {:foo "bar" :baz "buzz"} result)))))) 44 | -------------------------------------------------------------------------------- /test/vault/secret/database_test.clj: -------------------------------------------------------------------------------- 1 | (ns vault.secret.database-test 2 | (:require 3 | [clojure.test :refer [is testing deftest]] 4 | [vault.client.http :as http] 5 | [vault.integration :refer [with-dev-server cli]] 6 | [vault.secret.database :as database])) 7 | 8 | 9 | (deftest with-mount 10 | (testing "different mounts" 11 | (let [client (http/http-client "https://foo.com")] 12 | (is (nil? (::database/mount client))) 13 | (is (= "test-mount" (::database/mount (database/with-mount client "test-mount"))))))) 14 | 15 | 16 | (deftest ^:service-required http-api 17 | (let [db-host (or (System/getenv "VAULT_POSTGRES_HOST") "localhost:5432") 18 | db-name (or (System/getenv "VAULT_POSTGRES_DATABASE") "postgres") 19 | admin-user (or (System/getenv "VAULT_POSTGRES_ADMIN_USER") "postgres") 20 | admin-pass (System/getenv "VAULT_POSTGRES_ADMIN_PASS") 21 | grant-role (or (System/getenv "VAULT_POSTGRES_ROLE") "postgres")] 22 | (when admin-pass 23 | (with-dev-server 24 | (cli "secrets" "enable" "database") 25 | (cli "write" "database/config/test-db" 26 | "plugin_name=postgresql-database-plugin" 27 | (format "connection_url=postgresql://{{username}}:{{password}}@%s/%s?sslmode=disable" 28 | db-host 29 | db-name) 30 | (str "username=" admin-user) 31 | (str "password=" admin-pass) 32 | "allowed_roles=*") 33 | (cli "write" "database/roles/postgres-role" 34 | "db_name=test-db" 35 | (str "creation_statements=CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; GRANT " 36 | grant-role " TO \"{{name}}\";") 37 | "revocation_statements=DROP ROLE IF EXISTS \"{{name}}\";") 38 | (testing "generate nonexistent role" 39 | (is (thrown? Exception 40 | (database/generate-credentials! client "foo")))) 41 | (testing "generate valid credentials" 42 | (let [creds (database/generate-credentials! client "postgres-role")] 43 | (is (string? (:username creds))) 44 | (is (string? (:password creds))) 45 | ;; TODO: test credentials somehow 46 | ,,,)))))) 47 | -------------------------------------------------------------------------------- /test/vault/sys/mounts_test.clj: -------------------------------------------------------------------------------- 1 | (ns vault.sys.mounts-test 2 | (:require 3 | [clojure.test :refer [is testing deftest]] 4 | [vault.integration :refer [with-dev-server]] 5 | [vault.sys.mounts :as sys.mounts])) 6 | 7 | 8 | (deftest ^:integration http-api 9 | (with-dev-server 10 | (testing "list-mounts" 11 | (let [result (sys.mounts/list-mounts client) 12 | cubbyhole (get result "cubbyhole/")] 13 | (is (= #{"cubbyhole/" "identity/" "secret/" "sys/"} 14 | (set (keys result)))) 15 | (is (map? cubbyhole)) 16 | (is (= "cubbyhole" (:type cubbyhole))) 17 | (is (string? (:uuid cubbyhole))) 18 | (is (string? (:description cubbyhole))))) 19 | (testing "enable-secrets!" 20 | (let [result (sys.mounts/enable-secrets! 21 | client 22 | "ldap" 23 | {:type "openldap" 24 | :description "LDAP secret storage" 25 | :config {:default-lease-ttl "6m"}}) 26 | ldap (get (sys.mounts/list-mounts client) "ldap/")] 27 | (is (nil? result)) 28 | (is (map? ldap)) 29 | (is (= "openldap" (:type ldap))) 30 | (is (= "LDAP secret storage" (:description ldap))) 31 | (is (= 360 (get-in ldap [:config :default-lease-ttl]))))) 32 | (testing "read-secrets-configuration" 33 | (let [result (sys.mounts/read-secrets-configuration client "ldap")] 34 | (is (= "openldap" (:type result))) 35 | (is (= "LDAP secret storage" (:description result))) 36 | (is (= 360 (get-in result [:config :default-lease-ttl]))))) 37 | (testing "tune-mount-configuration" 38 | (is (nil? (sys.mounts/tune-mount-configuration! 39 | client 40 | "ldap" 41 | {:default-lease-ttl 1800 42 | :max-lease-ttl 86400 43 | :description "new description"}))) 44 | (let [result (sys.mounts/read-mount-configuration client "ldap")] 45 | (is (= 1800 (:default-lease-ttl result))) 46 | (is (= 86400 (:max-lease-ttl result))) 47 | (is (= "new description" (:description result))))) 48 | (testing "disable-secrets!" 49 | (is (nil? (sys.mounts/disable-secrets! client "ldap"))) 50 | (let [result (sys.mounts/list-mounts client)] 51 | (is (= #{"cubbyhole/" "identity/" "secret/" "sys/"} 52 | (set (keys result)))))))) 53 | -------------------------------------------------------------------------------- /src/vault/sys/leases.clj: -------------------------------------------------------------------------------- 1 | (ns vault.sys.leases 2 | "The `/sys/leases` endpoint is used to view and manage leases in Vault. 3 | 4 | Reference: https://www.vaultproject.io/api-docs/system/leases" 5 | (:require 6 | [vault.client.http :as http] 7 | [vault.lease :as lease] 8 | [vault.util :as u]) 9 | (:import 10 | vault.client.http.HTTPClient)) 11 | 12 | 13 | ;; ## API Protocol 14 | 15 | (defprotocol API 16 | "The leases endpoint is used to manage secret leases in Vault." 17 | 18 | (read-lease 19 | [client lease-id] 20 | "Retrieve lease metadata.") 21 | 22 | (list-leases 23 | [client prefix] 24 | "Return a collection of lease ids under the given prefix. This endpoint 25 | requires sudo capability.") 26 | 27 | (renew-lease! 28 | [client lease-id] 29 | [client lease-id increment] 30 | "Renew a lease, requesting to extend the time it is valid for. The 31 | `increment` is a requested duration in seconds to extend the lease.") 32 | 33 | (revoke-lease! 34 | [client lease-id] 35 | "Revoke a lease, invalidating the secret it references.")) 36 | 37 | 38 | ;; ## HTTP Client 39 | 40 | (extend-type HTTPClient 41 | 42 | API 43 | 44 | (read-lease 45 | [client lease-id] 46 | (http/call-api 47 | client ::read-lease 48 | :put "sys/leases/lookup" 49 | {:info {::lease/id lease-id} 50 | :content-type :json 51 | :body {:lease_id lease-id}})) 52 | 53 | 54 | (list-leases 55 | [client prefix] 56 | (http/call-api 57 | client ::list-leases 58 | :list (u/join-path "sys/leases/lookup" prefix) 59 | {:info {::prefix prefix}})) 60 | 61 | 62 | (renew-lease! 63 | ([client lease-id] 64 | (renew-lease! client lease-id nil)) 65 | ([client lease-id increment] 66 | (http/call-api 67 | client ::renew-lease! 68 | :put "sys/leases/renew" 69 | {:info {::lease/id lease-id} 70 | :content-type :json 71 | :body (cond-> {:lease_id lease-id} 72 | increment 73 | (assoc :increment increment)) 74 | :handle-response http/lease-info 75 | :on-success (fn update-lease 76 | [lease] 77 | (lease/update! client lease))}))) 78 | 79 | 80 | (revoke-lease! 81 | [client lease-id] 82 | (lease/delete! client lease-id) 83 | (http/call-api 84 | client ::revoke-lease! 85 | :put "sys/leases/revoke" 86 | {:info {::lease/id lease-id} 87 | :content-type :json 88 | :body {:lease_id lease-id}}))) 89 | -------------------------------------------------------------------------------- /bin/server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Run this script to start and configure a local Vault instance to test out the 4 | # vault-clj client. The REPL is already set up to talk to this server. 5 | 6 | set -eo pipefail 7 | trap cleanup SIGINT SIGTERM ERR EXIT 8 | 9 | usage() { 10 | cat <&2 -e "\n${CYAN}${1-}${NOFORMAT}" 28 | } 29 | 30 | cleanup() { 31 | trap - SIGINT SIGTERM ERR EXIT 32 | if [[ -n $VAULT_PID ]]; then 33 | kill -s INT "$VAULT_PID" 34 | sleep 1 35 | fi 36 | exit 37 | } 38 | 39 | setup_colors 40 | 41 | if [[ $1 = "-h" ]]; then 42 | usage 43 | fi 44 | 45 | VAULT_HOST="127.0.0.1" 46 | VAULT_PORT="${1:-8200}" 47 | 48 | # Make sure the port isn't already taken. 49 | if nc -z 127.0.0.1 $VAULT_PORT 2> /dev/null; then 50 | log "${RED}Vault port $VAULT_PORT is already taken - shut down any other local dev servers you are running or choose another port." 51 | exit 1 52 | fi 53 | 54 | export VAULT_ADDR="http://${VAULT_HOST}:${VAULT_PORT}" 55 | export VAULT_TOKEN="t0p-53cr3t" 56 | 57 | # Launch and background the server. 58 | vault server \ 59 | -dev \ 60 | -dev-listen-address="${VAULT_HOST}:${VAULT_PORT}" \ 61 | -dev-root-token-id="${VAULT_TOKEN}" \ 62 | -dev-no-store-token \ 63 | & 64 | 65 | VAULT_PID="$!" 66 | 67 | # Wait for server to be ready. 68 | attempts=10 69 | while ! nc -z "${VAULT_HOST}" "${VAULT_PORT}" 2> /dev/null; do 70 | if [[ $attempts -gt 0 ]]; then 71 | # keep waiting 72 | attempts=$(($attempts - 1)) 73 | sleep 1 74 | else 75 | # out of attempts - is the process still alive? 76 | if ! kill -0 "$VAULT_PID"; then 77 | log "${RED}Vault server process appears to be dead, uh-oh..." 78 | exit 2 79 | else 80 | log "${RED}Vault server not ready after initialization wait, killing..." 81 | kill "$VAULT_PID" 82 | exit 3 83 | fi 84 | fi 85 | done 86 | 87 | log "${YELLOW}Server ready - interrupt to shut down." 88 | wait 89 | -------------------------------------------------------------------------------- /test/vault/client/mock_test.clj: -------------------------------------------------------------------------------- 1 | (ns vault.client.mock-test 2 | (:require 3 | [clojure.java.io :as io] 4 | [clojure.test :refer [deftest testing is]] 5 | [vault.auth :as auth] 6 | [vault.client.mock :as mock] 7 | [vault.client.proto :as proto])) 8 | 9 | 10 | (deftest authentication 11 | (let [client (mock/mock-client "mock:-")] 12 | (testing "with bad input" 13 | (is (thrown-with-msg? IllegalArgumentException #"Client authentication must be a map" 14 | (proto/authenticate! client []))) 15 | (is (thrown-with-msg? IllegalArgumentException #"containing a client-token" 16 | (proto/authenticate! client {})))) 17 | (testing "with token string" 18 | (is (identical? client (proto/authenticate! client "t0p-53cr3t"))) 19 | (is (= {::auth/client-token "t0p-53cr3t"} 20 | (proto/auth-info client)))) 21 | (testing "with auth info" 22 | (is (identical? client (proto/authenticate! 23 | client 24 | {::auth/client-token "t0p-53cr3t" 25 | ::auth/lease-duration 12345}))) 26 | (is (= {::auth/client-token "t0p-53cr3t" 27 | ::auth/lease-duration 12345} 28 | (proto/auth-info client)))))) 29 | 30 | 31 | (deftest client-constructor 32 | (testing "with bad scheme" 33 | (is (thrown-with-msg? IllegalArgumentException #"Mock client must be constructed with a map of data or a URN with scheme 'mock'" 34 | (mock/mock-client "mook:abc")))) 35 | (testing "without fixture" 36 | (let [client (mock/mock-client "mock:-")] 37 | (is (satisfies? proto/Client client)) 38 | (is (= {} @(:memory client))))) 39 | (testing "with missing fixture" 40 | (let [client (mock/mock-client "mock:target/test/fixture.edn")] 41 | (is (satisfies? proto/Client client)) 42 | (is (= {} @(:memory client))))) 43 | (testing "with fixture resource" 44 | (let [fixture (io/file "test/vault/client/fixture.edn")] 45 | (.deleteOnExit fixture) 46 | (try 47 | (spit fixture "{:secrets {\"kv\" {}}}") 48 | (let [client (mock/mock-client "mock:vault/client/fixture.edn")] 49 | (is (satisfies? proto/Client client)) 50 | (is (= {:secrets {"kv" {}}} 51 | @(:memory client)))) 52 | (finally 53 | (io/delete-file fixture :gone))))) 54 | (testing "with fixture file" 55 | (let [fixture (io/file "target/test/fixture.edn")] 56 | (.deleteOnExit fixture) 57 | (try 58 | (io/make-parents fixture) 59 | (spit fixture "{:secrets {\"kv\" {}}}") 60 | (let [client (mock/mock-client "mock:target/test/fixture.edn")] 61 | (is (satisfies? proto/Client client)) 62 | (is (= {:secrets {"kv" {}}} 63 | @(:memory client)))) 64 | (finally 65 | (io/delete-file fixture :gone)))))) 66 | -------------------------------------------------------------------------------- /test/vault/sys/auth_test.clj: -------------------------------------------------------------------------------- 1 | (ns vault.sys.auth-test 2 | (:require 3 | [clojure.test :refer [is testing deftest]] 4 | [vault.client.mock :refer [mock-client]] 5 | [vault.integration :refer [with-dev-server]] 6 | [vault.sys.auth :as sys.auth])) 7 | 8 | 9 | (deftest mock-api 10 | (let [client (mock-client)] 11 | (testing "list-methods" 12 | (let [result (sys.auth/list-methods client)] 13 | (is (= ["token/"] (keys result))) 14 | (is (map? (get result "token/"))) 15 | (is (= "token" (get-in result ["token/" :type]))) 16 | (is (string? (get-in result ["token/" :description]))))) 17 | (testing "read-method-tuning" 18 | (testing "on token path" 19 | (let [result (sys.auth/read-method-tuning client "token/")] 20 | (is (map? result)) 21 | (is (string? (:description result))) 22 | (is (pos-int? (:default-lease-ttl result))))) 23 | (testing "on missing path" 24 | (is (thrown-with-msg? Exception #"cannot fetch sysview" 25 | (sys.auth/read-method-tuning client "foo/"))))))) 26 | 27 | 28 | (deftest ^:integration http-api 29 | (with-dev-server 30 | (testing "list-methods" 31 | (let [result (sys.auth/list-methods client) 32 | auth (get result "token/")] 33 | (is (= ["token/"] (keys result))) 34 | (is (map? auth)) 35 | (is (= "token" (:type auth))) 36 | (is (string? (:uuid auth))) 37 | (is (string? (:description auth))))) 38 | (testing "read-method-tuning" 39 | (testing "on token path" 40 | (let [result (sys.auth/read-method-tuning client "token/")] 41 | (is (map? result)) 42 | (is (string? (:description result))) 43 | (is (pos-int? (:default-lease-ttl result))))) 44 | (testing "on missing path" 45 | (is (thrown-with-msg? Exception #"cannot fetch sysview" 46 | (sys.auth/read-method-tuning client "foo/"))))) 47 | (testing "enable-method!" 48 | (is (nil? (sys.auth/enable-method! client "github" {:type "github", :description "test github auth"}))) 49 | (let [result (sys.auth/list-methods client) 50 | auth (get result "github/")] 51 | (is (contains? result "github/")) 52 | (is (map? auth)) 53 | (is (= "github" (:type auth))) 54 | (is (string? (:uuid auth))) 55 | (is (= "test github auth" (:description auth))))) 56 | (testing "tune-method!" 57 | (is (nil? (sys.auth/tune-method! client "github" {:default-lease-ttl 1800, :max-lease-ttl 86400}))) 58 | (let [result (sys.auth/read-method-tuning client "github")] 59 | (is (map? result)) 60 | (is (= 1800 (:default-lease-ttl result))) 61 | (is (= 86400 (:max-lease-ttl result))))) 62 | (testing "disable-method!" 63 | (is (nil? (sys.auth/disable-method! client "github"))) 64 | (let [result (sys.auth/list-methods client)] 65 | (is (= ["token/"] (keys result))))))) 66 | -------------------------------------------------------------------------------- /src/vault/sys/wrapping.clj: -------------------------------------------------------------------------------- 1 | (ns vault.sys.wrapping 2 | "The `/sys/wrapping` endpoint is used to wrap secrets and lookup, rewrap, and 3 | unwrap tokens. 4 | 5 | Reference: 6 | - https://www.vaultproject.io/api-docs/system/wrapping-lookup 7 | - https://www.vaultproject.io/api-docs/system/wrapping-rewrap 8 | - https://www.vaultproject.io/api-docs/system/wrapping-unwrap 9 | - https://www.vaultproject.io/api-docs/system/wrapping-wrap" 10 | (:require 11 | [vault.client.http :as http] 12 | [vault.util :as u]) 13 | (:import 14 | vault.client.http.HTTPClient)) 15 | 16 | 17 | ;; ## API Protocol 18 | 19 | (defprotocol API 20 | "The wrapping endpoint is used to manage response-wrapped tokens." 21 | 22 | (lookup 23 | [client token-id] 24 | "Read the wrapping properties for the given token.") 25 | 26 | (rewrap 27 | [client token-id] 28 | "Rotate the given wrapping token and refresh its TTL. Returns the new token 29 | info.") 30 | 31 | (unwrap 32 | [client] 33 | [client token-id] 34 | "Read the original response inside the given wrapping token.") 35 | 36 | (wrap 37 | [client data ttl] 38 | "Wrap the given map of data inside a response-wrapped token with the 39 | specified time-to-live. The TTL can be either an integer number of seconds 40 | or a string duration such as `15s`, `20m`, `25h`, etc. Returns the new 41 | token info.")) 42 | 43 | 44 | ;; ## HTTP Client 45 | 46 | (defn- kebabify-body-wrap-info 47 | [body] 48 | (u/kebabify-keys (get body "wrap_info"))) 49 | 50 | 51 | (extend-type HTTPClient 52 | 53 | API 54 | 55 | (lookup 56 | [client token-id] 57 | (http/call-api 58 | client ::lookup 59 | :post "sys/wrapping/lookup" 60 | {:content-type :json 61 | :body {:token token-id} 62 | :handle-response u/kebabify-body-data})) 63 | 64 | 65 | (rewrap 66 | [client token-id] 67 | (http/call-api 68 | client ::rewrap 69 | :post "sys/wrapping/rewrap" 70 | {:content-type :json 71 | :body {:token token-id} 72 | :handle-response kebabify-body-wrap-info})) 73 | 74 | 75 | (unwrap 76 | ([client] 77 | (http/call-api 78 | client ::unwrap 79 | :post "sys/wrapping/unwrap" 80 | {:content-type :json 81 | :handle-response (some-fn u/kebabify-body-auth 82 | u/kebabify-body-data)})) 83 | ([client token-id] 84 | (http/call-api 85 | client ::unwrap 86 | :post "sys/wrapping/unwrap" 87 | {:content-type :json 88 | :body {:token token-id} 89 | :handle-response (some-fn u/kebabify-body-auth 90 | u/kebabify-body-data)}))) 91 | 92 | 93 | (wrap 94 | [client data ttl] 95 | (when-not (map? data) 96 | (throw (IllegalArgumentException. "Data to wrap must be a map."))) 97 | (http/call-api 98 | client ::wrap 99 | :post "sys/wrapping/wrap" 100 | {:headers {"X-Vault-Wrap-TTL" ttl} 101 | :content-type :json 102 | :body data 103 | :handle-response kebabify-body-wrap-info}))) 104 | -------------------------------------------------------------------------------- /src/vault/auth.clj: -------------------------------------------------------------------------------- 1 | (ns vault.auth 2 | "High-level namespace for client authentication." 3 | (:refer-clojure :exclude [set!]) 4 | (:require 5 | [clojure.tools.logging :as log] 6 | [vault.util :as u])) 7 | 8 | 9 | ;; ## Data Specs 10 | 11 | (def ^:private auth-spec 12 | "Specification for authentication data maps." 13 | {;; Authentication token string. 14 | ::token string? 15 | 16 | ;; Token accessor id string. 17 | ::accessor string? 18 | 19 | ;; Display name for the token. 20 | ::display-name string? 21 | 22 | ;; Number of seconds the token lease is valid for. 23 | ::lease-duration nat-int? 24 | 25 | ;; Set of policies applied to the token. 26 | ::policies 27 | (fn valid-policies? 28 | [policies] 29 | (and (set? policies) (every? string? policies))) 30 | 31 | ;; Whether the token is an orphan. 32 | ::orphan? boolean? 33 | 34 | ;; Whether the token is renewable. 35 | ::renewable? boolean? 36 | 37 | ;; Instant after which the token can be renewed again. 38 | ::renew-after inst? 39 | 40 | ;; Instant the token was created. 41 | ::created-at inst? 42 | 43 | ;; Instant the token expires. 44 | ::expires-at inst?}) 45 | 46 | 47 | (defn valid? 48 | "True if the auth information map conforms to the spec." 49 | [auth] 50 | (u/validate auth-spec auth)) 51 | 52 | 53 | ;; ## General Functions 54 | 55 | (defn expires-within? 56 | "True if the auth will expire within `ttl` seconds." 57 | [auth ttl] 58 | (if-let [expires-at (::expires-at auth)] 59 | (-> (u/now) 60 | (.plusSeconds ttl) 61 | (.isAfter expires-at)) 62 | false)) 63 | 64 | 65 | (defn expired? 66 | "True if the auth is expired." 67 | [auth] 68 | (expires-within? auth 0)) 69 | 70 | 71 | (defn renewable? 72 | "True if the auth token can be renewed right now." 73 | [auth] 74 | (and (::renewable? auth) 75 | (::expires-at auth) 76 | (not (expired? auth)))) 77 | 78 | 79 | ;; ## State Maintenance 80 | 81 | (defn new-state 82 | "Construct a new auth state store." 83 | [] 84 | (u/veil (atom {} :validator valid?))) 85 | 86 | 87 | (defn current 88 | "Return the current auth store state." 89 | [auth] 90 | @(u/unveil auth)) 91 | 92 | 93 | (defn set! 94 | "Set the auth store state to the provided info." 95 | [auth info] 96 | (let [state (u/unveil auth)] 97 | (reset! state info))) 98 | 99 | 100 | (defn maintain! 101 | "Maintain client authentication state. Returns a keyword indicating the 102 | maintenance result." 103 | [client f] 104 | (let [renew-within 60 105 | renew-backoff 60 106 | state (u/unveil (:auth client)) 107 | auth @state] 108 | (try 109 | (cond 110 | (expired? auth) 111 | :expired 112 | 113 | (and (renewable? auth) 114 | (expires-within? auth renew-within) 115 | (if-let [renew-after (::renew-after auth)] 116 | (.isAfter (u/now) renew-after) 117 | true)) 118 | (do 119 | (f) 120 | (swap! state assoc ::renew-after (.plusSeconds (u/now) renew-backoff)) 121 | :renewed) 122 | 123 | :else 124 | :current) 125 | (catch Exception ex 126 | (log/error ex "Failed to maintain Vault authentication" (ex-message ex)) 127 | :error)))) 128 | -------------------------------------------------------------------------------- /src/vault/secret/database.clj: -------------------------------------------------------------------------------- 1 | (ns vault.secret.database 2 | "The database secrets engine is used to manage dynamically-issued credentials 3 | for users of a database backend such as mysql, postgresql, mongodb, etc. The 4 | vault server uses a privileged 'root' user to create new users with randomized 5 | passwords on-demand for callers. 6 | 7 | Reference: https://www.vaultproject.io/api-docs/secret/databases" 8 | (:require 9 | [vault.client.http :as http] 10 | [vault.util :as u]) 11 | (:import 12 | vault.client.http.HTTPClient)) 13 | 14 | 15 | (def default-mount 16 | "Default mount point to use if one is not provided." 17 | "database") 18 | 19 | 20 | ;; ## API Protocol 21 | 22 | (defprotocol API 23 | "The database secrets engine is used to manage dynamic users in a backing 24 | database system." 25 | 26 | (with-mount 27 | [client mount] 28 | "Return an updated client which will resolve calls against the provided 29 | mount instead of the default. Passing `nil` will reset the client to the 30 | default.") 31 | 32 | (generate-credentials! 33 | [client role-name] 34 | [client role-name opts] 35 | "Generate a new set of dynamic credentials based on the named role. 36 | 37 | Options: 38 | 39 | - `:refresh?` (boolean) 40 | 41 | Always make a call for fresh data, even if a cached secret lease is 42 | available. 43 | 44 | - `:renew?` (boolean) 45 | 46 | If true, attempt to automatically renew the credentials lease when near 47 | expiry. (Default: `false`) 48 | 49 | - `:renew-within` (integer) 50 | 51 | Renew the secret when within this many seconds of the lease expiry. 52 | (Default: `60`) 53 | 54 | - `:renew-increment` (integer) 55 | 56 | How long to request credentials be renewed for, in seconds. 57 | 58 | - `:on-renew` (fn) 59 | 60 | A function to call with the updated lease information after the 61 | credentials have been renewed. 62 | 63 | - `:rotate?` (boolean) 64 | 65 | If true, attempt to read a new set of credentials when they can no longer 66 | be renewed. (Default: `false`) 67 | 68 | - `:rotate-within` (integer) 69 | 70 | Rotate the secret when within this many seconds of the lease expiry. 71 | (Default: `60`) 72 | 73 | - `:on-rotate` (fn) 74 | 75 | A function to call with the new credentials after they have been 76 | rotated. 77 | 78 | - `:on-error` (fn) 79 | 80 | A function to call with any exceptions encountered while renewing or 81 | rotating the credentials.")) 82 | 83 | 84 | ;; ## HTTP Client 85 | 86 | (extend-type HTTPClient 87 | 88 | API 89 | 90 | (with-mount 91 | [client mount] 92 | (if (some? mount) 93 | (assoc client ::mount mount) 94 | (dissoc client ::mount))) 95 | 96 | 97 | (generate-credentials! 98 | ([client role-name] 99 | (generate-credentials! client role-name {})) 100 | ([client role-name opts] 101 | (let [mount (::mount client default-mount) 102 | api-path (u/join-path mount "creds" role-name) 103 | cache-key [::role mount role-name]] 104 | (http/generate-rotatable-credentials! 105 | client ::generate-credentials! 106 | :get api-path 107 | {:info {::mount mount, ::role role-name} 108 | :cache-key cache-key} 109 | opts))))) 110 | -------------------------------------------------------------------------------- /test/vault/util_test.clj: -------------------------------------------------------------------------------- 1 | (ns vault.util-test 2 | (:require 3 | [clojure.test :refer [deftest testing is]] 4 | [vault.util :as u]) 5 | (:import 6 | java.time.Instant)) 7 | 8 | 9 | (deftest misc-utils 10 | (testing "update-some" 11 | (is (= {:foo true} (u/update-some {:foo true} :bar inc))) 12 | (is (= {:foo true, :bar 124} 13 | (u/update-some {:foo true, :bar 123} :bar inc))))) 14 | 15 | 16 | (deftest kebab-casing 17 | (testing "kebab-keyword" 18 | (is (= :foo-bar (u/kebab-keyword :foo-bar))) 19 | (is (= :a-b (u/kebab-keyword :a_b))) 20 | (is (= :x-y-z (u/kebab-keyword "x_y_z")))) 21 | (testing "kebabify-keys" 22 | (is (= {:one-two [{:x 123 23 | :y-z true}] 24 | :three "456"} 25 | (u/kebabify-keys 26 | {"one_two" [{"x" 123 27 | "y_z" true}] 28 | "three" "456"})))) 29 | (testing "kebabify-body-data" 30 | (is (= {:foo-bar 123 31 | :baz true} 32 | (u/kebabify-body-data 33 | {"abc" "def" 34 | "data" {"foo_bar" 123 35 | "baz" true} 36 | "xyz" 890}))))) 37 | 38 | 39 | (deftest snake-casing 40 | (testing "snake-str" 41 | (is (= "foo_bar" (u/snake-str "foo_bar"))) 42 | (is (= "a_b" (u/snake-str :a_b))) 43 | (is (= "x_y_z" (u/snake-str :x-y-z)))) 44 | (testing "snakify-keys" 45 | (is (= {"one_two" [{"x" 123 46 | "y_z" true}] 47 | "three" "456"} 48 | (u/snakify-keys 49 | {:one-two [{:x 123 50 | :y-z true}] 51 | :three "456"}))))) 52 | 53 | 54 | (deftest string-casing 55 | (testing "stringify-key" 56 | (is (= "abc" (u/stringify-key "abc"))) 57 | (is (= "123" (u/stringify-key 123))) 58 | (is (= "foo" (u/stringify-key :foo))) 59 | (is (= "foo-bar" (u/stringify-key :foo-bar))) 60 | (is (= "foo_bar" (u/stringify-key :foo_bar))) 61 | (is (= "foo.bar/baz" (u/stringify-key :foo.bar/baz)))) 62 | (testing "stringify-keys" 63 | (is (= {"foo" [{"x" 123, "y/z" true}] 64 | "bar" "456"} 65 | (u/stringify-keys 66 | {:foo [{:x 123, :y/z true}] 67 | :bar "456"}))))) 68 | 69 | 70 | (deftest encoding 71 | (testing "hex" 72 | (is (= "a0" (u/hex-encode (byte-array [160])))) 73 | (is (= "0123456789abcdef" (u/hex-encode (byte-array [0x01 0x23 0x45 0x67 0x89 0xab 0xcd 0xef]))))) 74 | (testing "base64" 75 | (let [good-news "Good news, everyone!" 76 | data (.getBytes good-news)] 77 | (is (= "R29vZCBuZXdzLCBldmVyeW9uZSE=" (u/base64-encode data))) 78 | (is (= "R29vZCBuZXdzLCBldmVyeW9uZSE=" (u/base64-encode good-news))) 79 | (is (= good-news (String. (u/base64-decode "R29vZCBuZXdzLCBldmVyeW9uZSE="))))))) 80 | 81 | 82 | (deftest paths 83 | (testing "trim-path" 84 | (is (= "foo" (u/trim-path "foo"))) 85 | (is (= "foo" (u/trim-path "/foo"))) 86 | (is (= "foo" (u/trim-path "foo/"))) 87 | (is (= "foo" (u/trim-path "/foo/"))) 88 | (is (= "foo/bar/baz" (u/trim-path "/foo/bar/baz/")))) 89 | (testing "join-path" 90 | (is (= "foo/bar" (u/join-path "foo" "bar"))) 91 | (is (= "foo/bar/baz/qux" (u/join-path "foo/bar/" "/baz" "qux/"))))) 92 | 93 | 94 | (deftest time-controls 95 | (let [t (Instant/parse "2021-08-31T22:06:17Z")] 96 | (u/with-now t 97 | (is (= t (u/now))) 98 | (is (= 1630447577000 (u/now-milli)))))) 99 | -------------------------------------------------------------------------------- /test/vault/auth/approle_test.clj: -------------------------------------------------------------------------------- 1 | (ns vault.auth.approle-test 2 | (:require 3 | [clojure.test :refer [deftest is testing]] 4 | [vault.auth :as auth] 5 | [vault.auth.approle :as approle] 6 | [vault.client :as vault] 7 | [vault.integration :refer [with-dev-server test-client cli]])) 8 | 9 | 10 | (defn- assert-authenticated-map 11 | [auth] 12 | (is (boolean? (:renewable auth))) 13 | (is (pos-int? (:lease-duration auth))) 14 | (is (string? (:accessor auth))) 15 | (is (string? (:client-token auth))) 16 | (is (map? (:metadata auth))) 17 | (is (coll? (:policies auth)))) 18 | 19 | 20 | (deftest ^:integration http-api 21 | (with-dev-server 22 | (testing "with default mount" 23 | (is (nil? (::approle/mount client))) 24 | (cli "auth" "enable" "approle") 25 | (testing "create and read approle" 26 | (let [role-properties {:bind-secret-id true 27 | :secret-id-bound-cidrs ["127.0.0.1/32" "192.0.2.0/23" "192.0.2.0/24"] 28 | :secret-id-num-uses 10 29 | :secret-id-ttl 75 30 | :local-secret-ids false 31 | :token-ttl 600 32 | :token-max-ttl 1000 33 | :token-policies ["foo-policy-1" "foo-policy-2"] 34 | :token-bound-cidrs ["127.0.0.1" "192.0.2.0/23" "192.0.2.0/24"] 35 | :token-explicit-max-ttl 1000 36 | :token-no-default-policy false 37 | :token-num-uses 2 38 | :token-period 0 39 | :token-type "service"}] 40 | (approle/configure-role! client "foo" role-properties) 41 | (is (= role-properties (approle/read-role client "foo"))))) 42 | (testing "list approles" 43 | (approle/configure-role! client "baz" {:secret-id-ttl "1m"}) 44 | (is (= #{"foo" "baz"} 45 | (into #{} (:keys (approle/list-roles client)))))) 46 | (testing "login" 47 | (let [role-id (:role-id (approle/read-role-id client "foo")) 48 | secret-id (:secret-id (approle/generate-secret-id! client "foo")) 49 | original-auth-info (vault/auth-info client) 50 | response (approle/login client role-id secret-id) 51 | auth-info (vault/auth-info client)] 52 | (assert-authenticated-map response) 53 | (is (= (:client-token response) 54 | (::auth/token auth-info))) 55 | (is (not= (::auth/token original-auth-info) 56 | (::auth/token auth-info)))))) 57 | (testing "with alternate mount" 58 | (cli "auth" "enable" "-path=auth-test" "approle") 59 | (let [client' (approle/with-mount (test-client) "auth-test") 60 | role-id (do (approle/configure-role! client' "bar" {:secret-id-ttl "1m"}) 61 | (:role-id (approle/read-role-id client' "bar"))) 62 | secret-id (:secret-id (approle/generate-secret-id! client' "bar")) 63 | original-auth-info (vault/auth-info client') 64 | response (approle/login client' role-id secret-id) 65 | auth-info (vault/auth-info client')] 66 | (is (= "auth-test" (::approle/mount client'))) 67 | (assert-authenticated-map response) 68 | (is (= (:client-token response) 69 | (::auth/token auth-info))) 70 | (is (not= (::auth/token original-auth-info) 71 | (::auth/token auth-info))))))) 72 | -------------------------------------------------------------------------------- /src/vault/sys/mounts.clj: -------------------------------------------------------------------------------- 1 | (ns vault.sys.mounts 2 | "The `/sys/mounts` endpoint is used to manage secrets engines in Vault. 3 | 4 | Reference: https://www.vaultproject.io/api-docs/system/mounts" 5 | (:require 6 | [vault.client.http :as http] 7 | [vault.util :as u]) 8 | (:import 9 | vault.client.http.HTTPClient)) 10 | 11 | 12 | ;; ## API Protocol 13 | 14 | (defprotocol API 15 | "Methods for managing secrets engines in Vault." 16 | 17 | (list-mounts 18 | [client] 19 | "List all the mounted secrets engines. Returns a map of secrets engines to 20 | their configurations.") 21 | 22 | (enable-secrets! 23 | [client path params] 24 | "Enable a new secrets engine at the given path. After enabling, this engine 25 | can be accessed and configured via the specified path. Returns nil. 26 | 27 | Parameters: 28 | 29 | - `:type` (string) 30 | 31 | The type of the backend, such as \"aws\" or \"openldap\". 32 | 33 | - `:description (optional, string) 34 | 35 | Human-friendly description of the mount. 36 | 37 | - `:config` (optional, map) 38 | 39 | Configuration options for this mount. 40 | 41 | - `:options` (optional, map) 42 | 43 | Mount type specific options that are passed to the backend. 44 | 45 | See the Vault API docs for details.") 46 | 47 | (disable-secrets! 48 | [client path] 49 | "Disable the mount point specified by the given path.") 50 | 51 | (read-secrets-configuration 52 | [client path] 53 | "Read the configuration of the secrets engine mounted at the given path.") 54 | 55 | (read-mount-configuration 56 | [client path] 57 | "Read the given mount's configuration. 58 | 59 | Unlike the [[read-secrets-configuration]] method, this will return the 60 | current time in seconds for each TTL, which may be the system default or a 61 | mount-specific value.") 62 | 63 | (tune-mount-configuration! 64 | [client path params] 65 | "Tune the configuration parameters for the given mount point. Returns 66 | `nil`. 67 | 68 | See the Vault API docs for available parameters.")) 69 | 70 | 71 | ;; ## HTTP Client 72 | 73 | (extend-type HTTPClient 74 | 75 | API 76 | 77 | (list-mounts 78 | [client] 79 | (http/call-api 80 | client ::list-mounts 81 | :get "sys/mounts" 82 | {:handle-response 83 | (fn handle-response 84 | [body] 85 | (into {} 86 | (map (juxt key (comp u/kebabify-keys val))) 87 | (get body "data")))})) 88 | 89 | 90 | (enable-secrets! 91 | [client path params] 92 | (http/call-api 93 | client ::enable-secrets! 94 | :post (u/join-path "sys/mounts" path) 95 | {:info {::path path, ::type (:type params)} 96 | :content-type :json 97 | :body (u/snakify-keys params)})) 98 | 99 | 100 | (disable-secrets! 101 | [client path] 102 | (http/call-api 103 | client ::disable-secrets! 104 | :delete (u/join-path "sys/mounts" path) 105 | {:info {::path path}})) 106 | 107 | 108 | (read-secrets-configuration 109 | [client path] 110 | (http/call-api 111 | client ::read-secrets-configuration 112 | :get (u/join-path "sys/mounts" path) 113 | {:info {::path path} 114 | :handle-response u/kebabify-body-data})) 115 | 116 | 117 | (read-mount-configuration 118 | [client path] 119 | (http/call-api 120 | client ::read-mount-configuration 121 | :get (u/join-path "sys/mounts" path "tune") 122 | {:info {::path path} 123 | :handle-response u/kebabify-body-data})) 124 | 125 | 126 | (tune-mount-configuration! 127 | [client path params] 128 | (http/call-api 129 | client ::tune-mount-configuration! 130 | :post (u/join-path "sys/mounts" path "tune") 131 | {:info {::path path} 132 | :content-type :json 133 | :body (u/snakify-keys params)}))) 134 | -------------------------------------------------------------------------------- /doc/control-flow.md: -------------------------------------------------------------------------------- 1 | # Control Flow 2 | 3 | A _control flow handler_ defines a collection of functions which determine how 4 | requests and responses are handled through the Vault client. The goal of this 5 | is to enable consumers to decide whether they want the simplicity of 6 | synchronous (blocking) calls to Vault, the flexibility of async calls, or 7 | something more sophisticated such as tracing or automatic retries. 8 | 9 | 10 | ## Built-in Handlers 11 | 12 | The library provides three flow handlers out of the box: 13 | 14 | - `sync-handler` (default) 15 | 16 | This is the default handler and blocks the calling thread until a result is 17 | ready. This is the simplest option, and matches the behavior in 1.x. 18 | 19 | - `promise-handler` 20 | 21 | This handler returns a Clojure `promise` to the caller, which yields the 22 | result on success or an exception on error. Note that this _returns_ the 23 | exception, which is a little unusual. You can use `flow/await` to have this 24 | throw instead. 25 | 26 | - `completable-future-handler` 27 | 28 | This handler uses Java's `CompletableFuture` as an asynchronous container, 29 | which will yield the result on success or throw an exception on error. Note 30 | that this handler is not supported in Babashka. 31 | 32 | 33 | ## Call State 34 | 35 | To support further extension, the control flow protocol has a notion of an 36 | "internal state" which is distinct from the value that is ultimately returned 37 | to the caller. The client passes this state around its methods, and it may 38 | contain more information such as retries remaining, tracing state, etc. 39 | 40 | In very simple cases, the state and the result might be the same - for example, 41 | in the `promise-handler` the state is just a `promise` which is also returned 42 | to the caller. When the request completes, the promise is fulfilled with either 43 | the success result or the error exception. 44 | 45 | 46 | ## Advanced Example 47 | 48 | This is an example of building a more advanced control-flow handler which: 49 | - utilizes the [manifold](https://github.com/clj-commons/manifold) library as 50 | an abstraction for asynchronous calls 51 | - integrates with [ken](https://github.com/amperity/ken) for observability 52 | instrumentation on all Vault API calls 53 | - automatically retries known exceptions on error 54 | 55 | ```clojure 56 | (require 57 | '[ken.core :as ken] 58 | '[ken.tap :as ktap] 59 | '[ken.trace :as trace] 60 | '[manifold.deferred :as d] 61 | '[manifold.time :as mt] 62 | '[vault.client.flow :as flow]) 63 | 64 | 65 | (deftype AdvancedHandler 66 | [retry-interval retry-duration] 67 | 68 | flow/Handler 69 | 70 | (call 71 | [_ info f] 72 | (let [start (System/nanoTime) 73 | deadline (+ start (* retry-duration 1000 1000)) 74 | span (atom (-> info 75 | (assoc :ken.event/label :vault.client/call) 76 | (ken/create-span) 77 | (merge (trace/child-attrs)) 78 | (trace/maybe-sample))) 79 | result (d/deferred) 80 | result' (d/finally 81 | result 82 | (bound-fn report 83 | [] 84 | (let [elapsed (/ (- (System/nanoTime) start) 1e6) 85 | event (-> @span 86 | (assoc :ken.event/duration elapsed) 87 | (ken/enrich-span))] 88 | (ktap/send event)))) 89 | state {:fn f 90 | :span span 91 | :start start 92 | :deadline deadline 93 | :result result}] 94 | (f state) 95 | result')) 96 | 97 | 98 | (on-success! 99 | [_ state info data] 100 | (trace/with-data (:span state) 101 | (ken/annotate info)) 102 | (d/success! (:result state) data)) 103 | 104 | 105 | (on-error! 106 | [_ state info ex] 107 | (trace/with-data (:span state) 108 | (ken/observe 109 | (assoc info 110 | :ken.event/label :vault.client/error 111 | :ken.event/level :warn 112 | :ken.event/error ex))) 113 | (if (and (retryable? ex) 114 | (< (+ (System/nanoTime) (* retry-interval 1000 1000)) 115 | (:deadline state))) 116 | ;; Kick off a new request 117 | (let [f (:fn state)] 118 | (mt/in retry-interval #(f state))) 119 | ;; Terminal error or out of retry time. 120 | (d/error! (:result state)))) 121 | 122 | 123 | (await 124 | [_ result] 125 | @result) 126 | 127 | 128 | (await 129 | [_ result timeout-ms timeout-val] 130 | (deref result timeout-ms timeout-val))) 131 | ``` 132 | -------------------------------------------------------------------------------- /src/vault/secret/aws.clj: -------------------------------------------------------------------------------- 1 | (ns vault.secret.aws 2 | "The AWS secrets engine generates AWS access credentials dynamically based on 3 | IAM policies. 4 | 5 | Reference: https://www.vaultproject.io/api-docs/secret/aws" 6 | (:require 7 | [vault.client.http :as http] 8 | [vault.util :as u]) 9 | (:import 10 | vault.client.http.HTTPClient)) 11 | 12 | 13 | (def default-mount 14 | "Default mount point to use if one is not provided." 15 | "aws") 16 | 17 | 18 | ;; ## API Protocol 19 | 20 | (defprotocol API 21 | "The AWS secrets engine generates AWS access credentials dynamically based on 22 | IAM policies." 23 | 24 | (with-mount 25 | [client mount] 26 | "Return an updated client which will resolve calls against the provided 27 | mount instead of the default. Passing `nil` will reset the client to the 28 | default.") 29 | 30 | (generate-user-credentials! 31 | [client user-name] 32 | [client user-name opts] 33 | "Generate a new set of dynamic IAM credentials based on the named user. 34 | 35 | Options: 36 | 37 | - `:refresh?` (boolean) 38 | 39 | Always make a call for fresh data, even if a cached secret lease is 40 | available. 41 | 42 | - `:renew?` (boolean) 43 | 44 | If true, attempt to automatically renew the credentials lease when near 45 | expiry. (Default: `false`) 46 | 47 | - `:renew-within` (integer) 48 | 49 | Renew the secret when within this many seconds of the lease expiry. 50 | (Default: `60`) 51 | 52 | - `:renew-increment` (integer) 53 | 54 | How long to request credentials be renewed for, in seconds. 55 | 56 | - `:on-renew` (fn) 57 | 58 | A function to call with the updated lease information after the 59 | credentials have been renewed. 60 | 61 | - `:rotate?` (boolean) 62 | 63 | If true, attempt to read a new set of credentials when they can no longer 64 | be renewed. (Default: `false`) 65 | 66 | - `:rotate-within` (integer) 67 | 68 | Rotate the secret when within this many seconds of the lease expiry. 69 | (Default: `60`) 70 | 71 | - `:on-rotate` (fn) 72 | 73 | A function to call with the new credentials after they have been 74 | rotated. 75 | 76 | - `:on-error` (fn) 77 | 78 | A function to call with any exceptions encountered while renewing or 79 | rotating the credentials.") 80 | 81 | (generate-role-credentials! 82 | [client role-name] 83 | [client role-name opts] 84 | "Generate a new set of dynamic IAM credentials based on the named role. 85 | 86 | Options: 87 | 88 | - `:refresh?` (boolean) 89 | 90 | Always make a call for fresh data, even if a cached secret lease is 91 | available. 92 | 93 | - `:rotate?` (boolean) 94 | 95 | If true, attempt to read a new set of credentials when they can no longer 96 | be renewed. (Default: `false`) 97 | 98 | - `:rotate-within` (integer) 99 | 100 | Rotate the secret when within this many seconds of the lease expiry. 101 | (Default: `60`) 102 | 103 | - `:on-rotate` (fn) 104 | 105 | A function to call with the new credentials after they have been 106 | rotated. 107 | 108 | - `:on-error` (fn) 109 | 110 | A function to call with any exceptions encountered while generating or 111 | rotating the credentials.")) 112 | 113 | 114 | ;; ## HTTP Client 115 | 116 | (extend-type HTTPClient 117 | 118 | API 119 | 120 | (with-mount 121 | [client mount] 122 | (if (some? mount) 123 | (assoc client ::mount mount) 124 | (dissoc client ::mount))) 125 | 126 | 127 | (generate-user-credentials! 128 | ([client user-name] 129 | (generate-user-credentials! client user-name {})) 130 | ([client user-name opts] 131 | (let [mount (::mount client default-mount) 132 | api-path (u/join-path mount "creds" user-name) 133 | cache-key [::user mount user-name]] 134 | (http/generate-rotatable-credentials! 135 | client ::generate-user-credentials! 136 | :get api-path 137 | {:info {::mount mount, ::user user-name} 138 | :cache-key cache-key} 139 | opts)))) 140 | 141 | 142 | (generate-role-credentials! 143 | ([client role-name] 144 | (generate-role-credentials! client role-name {})) 145 | ([client role-name opts] 146 | (let [mount (::mount client default-mount) 147 | api-path (u/join-path mount "sts" role-name) 148 | cache-key [::role mount role-name]] 149 | (http/generate-rotatable-credentials! 150 | client ::generate-role-credentials! 151 | :get api-path 152 | {:info {::mount mount, ::role role-name} 153 | :cache-key cache-key} 154 | (assoc opts 155 | ;; STS credentials are not renewable 156 | :renew? false)))))) 157 | -------------------------------------------------------------------------------- /src/vault/sys/auth.clj: -------------------------------------------------------------------------------- 1 | (ns vault.sys.auth 2 | "The `/sys/auth` endpoint is used to list, create, update, and delete auth 3 | methods. Auth methods convert user or machine-supplied information into a 4 | token which can be used for all future requests. 5 | 6 | Reference: https://www.vaultproject.io/api-docs/system/auth" 7 | (:require 8 | [vault.client.http :as http] 9 | [vault.client.mock :as mock] 10 | [vault.util :as u]) 11 | (:import 12 | vault.client.http.HTTPClient 13 | vault.client.mock.MockClient)) 14 | 15 | 16 | ;; ## API Protocol 17 | 18 | (defprotocol API 19 | "The health endpoint is used to check the health status of Vault." 20 | 21 | (list-methods 22 | [client] 23 | "List all enabled auth methods. Returns a map of endpoints to their 24 | configurations.") 25 | 26 | (enable-method! 27 | [client path params] 28 | "Enable a new auth method at the given path under the `auth/` prefix. After 29 | enabling, the method can be accessed and configured via the specified path. 30 | Returns nil. 31 | 32 | Parameters: 33 | 34 | - `:type` (string) 35 | 36 | Name of the authentication method type, such as \"github\" or \"token\". 37 | 38 | - `:description` (optional, string) 39 | 40 | Human-friendly description of the auth method. 41 | 42 | - `:config` (optional, map) 43 | 44 | Configuration options for this auth method. 45 | 46 | See the Vault API docs for details.") 47 | 48 | (disable-method! 49 | [client path] 50 | "Disable the auth method at the given path. Returns nil.") 51 | 52 | (read-method-tuning 53 | [client path] 54 | "Read the tuning configuration for the auth method at the path. Returns a 55 | map of config.") 56 | 57 | (tune-method! 58 | [client path params] 59 | "Tune the configuration parameters for the auth method at the path. Returns 60 | `nil`. 61 | 62 | See the Vault API docs for available parameters.")) 63 | 64 | 65 | ;; ## Mock Client 66 | 67 | (extend-type MockClient 68 | 69 | API 70 | 71 | (list-methods 72 | [client] 73 | (mock/success-response 74 | client 75 | {"token/" {:accessor "auth_token_96109b84" 76 | :config {:default-lease-ttl 0 77 | :force-no-cache false 78 | :max-lease-ttl 0 79 | :token-type "default-service"} 80 | :description "token based credentials" 81 | :external-entropy-access false 82 | :local false 83 | :options nil 84 | :seal-wrap false 85 | :type "token" 86 | :uuid "fcd3aea9-d682-3143-72d3-938c3f666d62"}})) 87 | 88 | 89 | (read-method-tuning 90 | [client path] 91 | (if (= "token" (u/trim-path path)) 92 | (mock/success-response 93 | client 94 | {:default-lease-ttl 2764800, 95 | :description "token based credentials", 96 | :force-no-cache false, 97 | :max-lease-ttl 2764800, 98 | :token-type "default-service"}) 99 | (mock/error-response 100 | client 101 | (let [error (str "cannot fetch sysview for path \"" path \")] 102 | (ex-info (str "Vault API errors: " error) 103 | {:vault.client/errors [error] 104 | :vault.client/status 400})))))) 105 | 106 | 107 | ;; ## HTTP Client 108 | 109 | (extend-type HTTPClient 110 | 111 | API 112 | 113 | (list-methods 114 | [client] 115 | (http/call-api 116 | client ::list-methods 117 | :get "sys/auth" 118 | {:handle-response 119 | (fn handle-response 120 | [body] 121 | (into {} 122 | (map (juxt key (comp u/kebabify-keys val))) 123 | (get body "data")))})) 124 | 125 | 126 | (enable-method! 127 | [client path params] 128 | (http/call-api 129 | client ::enable-method! 130 | :post (u/join-path "sys/auth" path) 131 | {:info {::path path, ::type (:type params)} 132 | :content-type :json 133 | :body (u/snakify-keys params)})) 134 | 135 | 136 | (disable-method! 137 | [client path] 138 | (http/call-api 139 | client ::disable-method! 140 | :delete (u/join-path "sys/auth" path) 141 | {:info {::path path}})) 142 | 143 | 144 | (read-method-tuning 145 | [client path] 146 | (http/call-api 147 | client ::read-method-tuning 148 | :get (u/join-path "sys/auth" path "tune") 149 | {:info {::path path} 150 | :handle-response u/kebabify-body-data})) 151 | 152 | 153 | (tune-method! 154 | [client path params] 155 | (http/call-api 156 | client ::tune-method! 157 | :post (u/join-path "sys/auth" path "tune") 158 | {:info {::path path} 159 | :content-type :json 160 | :body (u/snakify-keys params)}))) 161 | -------------------------------------------------------------------------------- /test/vault/integration.clj: -------------------------------------------------------------------------------- 1 | (ns vault.integration 2 | "Integration test support code. Manages running a local Vault server in 3 | development mode in order to truly exercise the client code." 4 | (:require 5 | [clojure.java.io :as io] 6 | [clojure.java.shell :as shell] 7 | [clojure.string :as str] 8 | [vault.client :as vault]) 9 | (:import 10 | (java.net 11 | InetSocketAddress 12 | Socket 13 | SocketTimeoutException) 14 | java.util.List 15 | java.util.concurrent.TimeUnit)) 16 | 17 | 18 | ;; ## Development Server 19 | 20 | (def interface 21 | "Local interface to bind the server to." 22 | "127.0.0.1") 23 | 24 | 25 | (def port 26 | "Local port to bind the server to." 27 | 8205) 28 | 29 | 30 | (def address 31 | "Local address the development server is bound to." 32 | (str "http://" interface ":" port)) 33 | 34 | 35 | (def root-token 36 | "Root token set for the development server." 37 | "t0p-53cr3t") 38 | 39 | 40 | (defn- port-open? 41 | "Returns true if the given port is open, false otherwise." 42 | [host port] 43 | (let [socket-addr (InetSocketAddress. (str host) (long port)) 44 | socket (Socket.)] 45 | (try 46 | (.connect socket socket-addr 10) 47 | true 48 | (catch SocketTimeoutException _ 49 | false) 50 | (catch Exception _ 51 | false) 52 | (finally 53 | (.close socket))))) 54 | 55 | 56 | (defn start-server! 57 | "Start a local Vault development server process. Returns the child process 58 | object." 59 | ^Process 60 | [] 61 | ;; TODO: automatically find an available port? 62 | (when (port-open? interface port) 63 | (throw (IllegalStateException. 64 | (str "Cannot start vault dev server, port " port " is already " 65 | "bound on " interface " - check `lsof -i TCP:" port 66 | "` and kill the offending process.")))) 67 | (let [command ["vault" "server" "-dev" 68 | (str "-dev-listen-address=" (str interface ":" port)) 69 | (str "-dev-root-token-id=" root-token) 70 | "-dev-no-store-token"] 71 | work-dir (io/file "target/vault") 72 | builder (doto (ProcessBuilder. ^List command) 73 | (.directory work-dir) 74 | (.redirectErrorStream true) 75 | (.redirectOutput (io/file work-dir "vault.log")))] 76 | (.mkdirs work-dir) 77 | (.start builder))) 78 | 79 | 80 | (defn await-server 81 | "Wait until the server port is available, trying up to `n` times, sleeping 82 | for `ms` between each attempt." 83 | [n ms] 84 | (loop [i 0] 85 | (if (< i n) 86 | (when-not (port-open? interface port) 87 | (Thread/sleep ms) 88 | (recur (inc i))) 89 | (throw (ex-info (format "Vault server not available on port %d after %d attempts (%d ms)" 90 | port n (* n ms)) 91 | {:address address}))))) 92 | 93 | 94 | (defn stop-server! 95 | "Stop the local development server process." 96 | [^Process proc] 97 | (when (.isAlive proc) 98 | (.destroy proc) 99 | (when-not (.waitFor proc 5 TimeUnit/SECONDS) 100 | (binding [*out* *err*] 101 | (println "Server did not stop cleanly after 5 seconds! Terminating...")) 102 | (.destroyForcibly proc))) 103 | (let [exit (.exitValue proc)] 104 | (when-not (zero? exit) 105 | (binding [*out* *err*] 106 | (println "Vault server exited with code:" exit)))) 107 | nil) 108 | 109 | 110 | ;; ## Client Setup 111 | 112 | (defn test-client 113 | "Construct a new test client pointed at the local development server." 114 | ([] 115 | (test-client root-token)) 116 | ([token] 117 | (doto (vault/new-client address) 118 | (vault/authenticate! token)))) 119 | 120 | 121 | (defmacro with-dev-server 122 | "Macro which executes the provided body with a development vault server and 123 | initialized test client bound to `client`." 124 | [& body] 125 | `(let [proc# (start-server!)] 126 | (try 127 | (await-server 25 100) 128 | (let [~'client (test-client)] 129 | ~@body) 130 | (finally 131 | (stop-server! proc#))))) 132 | 133 | 134 | ;; ## Utilities 135 | 136 | (defn cli 137 | "Perform a vault command by shelling out to the command-line client. Useful 138 | for actions which have not been implemented in the Clojure client yet. 139 | Returns the parsed JSON result of the command, or throws an exception if the 140 | command fails." 141 | [& args] 142 | (let [result (shell/with-sh-env {"VAULT_ADDR" address 143 | "VAULT_TOKEN" root-token 144 | "VAULT_FORMAT" "json"} 145 | (apply shell/sh (cons "vault" args)))] 146 | (if (zero? (:exit result)) 147 | ;; Command succeeded, parse result. 148 | ;; TODO: parse json 149 | (:out result) 150 | ;; Command failed. 151 | (throw (ex-info (format "vault command failed: %s (%d)" 152 | (str/join " " args) 153 | (:exit result)) 154 | {:args args 155 | :exit (:exit result) 156 | :out (:out result) 157 | :err (:err result)}))))) 158 | -------------------------------------------------------------------------------- /test/vault/client/flow_test.clj: -------------------------------------------------------------------------------- 1 | (ns vault.client.flow-test 2 | (:require 3 | [clojure.test :refer [deftest testing is]] 4 | [vault.client.flow :as f]) 5 | (:import 6 | clojure.lang.IPending 7 | java.util.concurrent.CompletableFuture)) 8 | 9 | 10 | (deftest sync-helper 11 | (let [handler f/promise-handler 12 | client {:flow handler} 13 | api-fn (fn api-fn 14 | [client x y] 15 | (is (= 123 x)) 16 | (is (true? y)) 17 | (f/call 18 | (:flow client) nil 19 | (fn request 20 | [state] 21 | (f/on-success! handler state nil :ok))))] 22 | (is (= :ok (f/call-sync api-fn client 123 true))))) 23 | 24 | 25 | (deftest sync-response 26 | (let [handler f/sync-handler] 27 | (testing "success case" 28 | (let [result (f/call 29 | handler nil 30 | (fn request 31 | [state] 32 | (is (instance? IPending state)) 33 | (is (not (realized? state))) 34 | (is (any? (f/on-success! handler state nil :ok))) 35 | (is (realized? state))))] 36 | (is (= :ok result)) 37 | (is (= :ok (f/await handler result 1 :not-yet))) 38 | (is (= :ok (f/await handler result))))) 39 | (testing "error case" 40 | (is (thrown-with-msg? RuntimeException #"BOOM" 41 | (f/call 42 | handler nil 43 | (fn request 44 | [state] 45 | (is (instance? IPending state)) 46 | (is (not (realized? state))) 47 | (is (any? (f/on-error! handler state nil (RuntimeException. "BOOM")))) 48 | (is (realized? state))))))))) 49 | 50 | 51 | (deftest promise-response 52 | (let [handler f/promise-handler] 53 | (testing "success case" 54 | (let [state-ref (volatile! nil) 55 | result (f/call 56 | handler nil 57 | (fn request 58 | [state] 59 | (vreset! state-ref state) 60 | (is (instance? IPending state)) 61 | (is (not (realized? state)))))] 62 | (is (instance? IPending result)) 63 | (is (not (realized? result))) 64 | (is (= :not-yet (f/await handler result 1 :not-yet))) 65 | (is (any? (f/on-success! handler @state-ref nil :ok))) 66 | (is (realized? result)) 67 | (is (= :ok @result)) 68 | (is (= :ok (f/await handler result 1 :not-yet))) 69 | (is (= :ok (f/await handler result))))) 70 | (testing "error case" 71 | (let [state-ref (volatile! nil) 72 | result (f/call 73 | handler nil 74 | (fn request 75 | [state] 76 | (vreset! state-ref state) 77 | (is (instance? IPending state)) 78 | (is (not (realized? state)))))] 79 | (is (instance? IPending result)) 80 | (is (not (realized? result))) 81 | (is (= :not-yet (f/await handler result 1 :not-yet))) 82 | (is (any? (f/on-error! handler @state-ref nil (RuntimeException. "BOOM")))) 83 | (is (realized? result)) 84 | (is (instance? RuntimeException @result)) 85 | (is (= "BOOM" (ex-message @result))) 86 | (is (thrown-with-msg? RuntimeException #"BOOM" 87 | (f/await handler result 1 :not-yet))) 88 | (is (thrown-with-msg? RuntimeException #"BOOM" 89 | (f/await handler result))))))) 90 | 91 | 92 | (deftest completable-future-response 93 | (let [handler f/completable-future-handler] 94 | (testing "success case" 95 | (let [state-ref (volatile! nil) 96 | result (f/call 97 | handler nil 98 | (fn request 99 | [state] 100 | (vreset! state-ref state) 101 | (is (instance? CompletableFuture state)) 102 | (is (not (.isDone state)))))] 103 | (is (instance? CompletableFuture result)) 104 | (is (not (.isDone result))) 105 | (is (= :not-yet (f/await handler result 1 :not-yet))) 106 | (is (any? (f/on-success! handler @state-ref nil :ok))) 107 | (is (.isDone result)) 108 | (is (= :ok @result)) 109 | (is (= :ok (f/await handler result 1 :not-yet))) 110 | (is (= :ok (f/await handler result))))) 111 | (testing "error case" 112 | (let [state-ref (volatile! nil) 113 | result (f/call 114 | handler nil 115 | (fn request 116 | [state] 117 | (vreset! state-ref state) 118 | (is (instance? CompletableFuture state)) 119 | (is (not (.isDone state)))))] 120 | (is (instance? CompletableFuture result)) 121 | (is (not (.isDone result))) 122 | (is (= :not-yet (f/await handler result 1 :not-yet))) 123 | (is (any? (f/on-error! handler @state-ref nil (RuntimeException. "BOOM")))) 124 | (is (.isDone result)) 125 | (is (thrown-with-msg? Exception #"BOOM" 126 | @result)) 127 | (is (thrown-with-msg? Exception #"BOOM" 128 | (f/await handler result 1 :not-yet))) 129 | (is (thrown-with-msg? Exception #"BOOM" 130 | (f/await handler result))))))) 131 | -------------------------------------------------------------------------------- /src/vault/client/flow.cljc: -------------------------------------------------------------------------------- 1 | (ns vault.client.flow 2 | "A _control flow handler_ defines a collection of functions which determine 3 | how requests and responses are handled through the Vault client." 4 | (:refer-clojure :exclude [await]) 5 | #?@(:bb 6 | [] 7 | :clj 8 | [(:import 9 | (java.util.concurrent 10 | CompletableFuture 11 | TimeUnit 12 | TimeoutException))])) 13 | 14 | 15 | (defprotocol Handler 16 | "Protocol for a handler which controls how client requests should be exposed 17 | to the consumer of the Vault APIs." 18 | 19 | (call 20 | [handler info f] 21 | "Create a new state container and invoke the function on it to initiate a 22 | request. Returns the result object the client should see. The `info` map 23 | may contain additional observability information.") 24 | 25 | (on-success! 26 | [handler state info data] 27 | "Callback indicating a successful response with the given response data. 28 | Should modify the state; the result of this call is not used.") 29 | 30 | (on-error! 31 | [handler state info ex] 32 | "Callback indicating a failure response with the given exception. Should 33 | modify the state; the result of this call is not used.") 34 | 35 | (await 36 | [handler result] 37 | [handler result timeout-ms timeout-val] 38 | "Wait for the given call to complete, blocking the current thread if 39 | necessary. Returns the response value on success, throws an exception on 40 | failure, or returns `timeout-val` if supplied and `timeout-ms` milliseconds 41 | pass while waiting. 42 | 43 | This will be invoked on the value returned by the `call` method, not the 44 | internal state object.")) 45 | 46 | 47 | (defn call-sync 48 | "Call the given function on the client, passing any additional args. Waits 49 | for the result to be ready using the client's flow handler." 50 | [f client & args] 51 | (let [handler (:flow client) 52 | result (apply f client args)] 53 | (await handler result))) 54 | 55 | 56 | (defn throwing-deref 57 | "A variant of `deref` which will throw if the pending value yields an 58 | exception." 59 | ([pending] 60 | (throwing-deref pending nil nil)) 61 | ([pending timeout-ms timeout-val] 62 | (let [x (if timeout-ms 63 | (deref pending timeout-ms timeout-val) 64 | @pending)] 65 | (if (instance? Throwable x) 66 | (throw x) 67 | x)))) 68 | 69 | 70 | ;; ## Synchronous Handler 71 | 72 | (deftype SyncHandler 73 | [] 74 | 75 | Handler 76 | 77 | (call 78 | [_ _ f] 79 | (let [state (promise)] 80 | (f state) 81 | (throwing-deref state))) 82 | 83 | 84 | (on-success! 85 | [_ state _ data] 86 | (deliver state data)) 87 | 88 | 89 | (on-error! 90 | [_ state _ ex] 91 | (deliver state ex)) 92 | 93 | 94 | (await 95 | [_ result] 96 | result) 97 | 98 | 99 | (await 100 | [_ result _ _] 101 | result)) 102 | 103 | 104 | (alter-meta! #'->SyncHandler assoc :private true) 105 | 106 | 107 | (def sync-handler 108 | "The synchronous handler will block the thread calling the API and will 109 | return either the response data (on success) or throw an exception (on 110 | error)." 111 | (->SyncHandler)) 112 | 113 | 114 | ;; ## Promise Handler 115 | 116 | (deftype PromiseHandler 117 | [] 118 | 119 | Handler 120 | 121 | (call 122 | [_ _ f] 123 | (let [state (promise)] 124 | (f state) 125 | state)) 126 | 127 | 128 | (on-success! 129 | [_ state _ data] 130 | (deliver state data)) 131 | 132 | 133 | (on-error! 134 | [_ state _ ex] 135 | (deliver state ex)) 136 | 137 | 138 | (await 139 | [_ result] 140 | (throwing-deref result)) 141 | 142 | 143 | (await 144 | [_ result timeout-ms timeout-val] 145 | (throwing-deref result timeout-ms timeout-val))) 146 | 147 | 148 | (alter-meta! #'->PromiseHandler assoc :private true) 149 | 150 | 151 | (def promise-handler 152 | "The promise handler will immediately return a `promise` value to the caller. 153 | The promise will asynchronously yield either the response data (on success) 154 | or an exception (on error). 155 | 156 | Note that dereferencing the promise will _return_ the error instead of 157 | throwing it, unless `await` is used." 158 | (->PromiseHandler)) 159 | 160 | 161 | ;; ## Completable Future Handler 162 | 163 | #?(:bb 164 | nil 165 | 166 | :clj 167 | (do 168 | (deftype CompletableFutureHandler 169 | [] 170 | 171 | Handler 172 | 173 | (call 174 | [_ _ f] 175 | (let [state (CompletableFuture.)] 176 | (f state) 177 | state)) 178 | 179 | 180 | (on-success! 181 | [_ state _ data] 182 | (.complete ^CompletableFuture state data)) 183 | 184 | 185 | (on-error! 186 | [_ state _ ex] 187 | (.completeExceptionally ^CompletableFuture state ex)) 188 | 189 | 190 | (await 191 | [_ result] 192 | (.get ^CompletableFuture result)) 193 | 194 | 195 | (await 196 | [_ result timeout-ms timeout-val] 197 | (try 198 | (.get ^CompletableFuture result timeout-ms TimeUnit/MILLISECONDS) 199 | (catch TimeoutException _ 200 | timeout-val)))) 201 | 202 | 203 | (alter-meta! #'->CompletableFutureHandler assoc :private true) 204 | 205 | 206 | (def completable-future-handler 207 | "The completable future handler will immediately return a `CompletableFuture` 208 | value to the caller. The future will asynchronously yield either the response 209 | data (on success) or an exception (on error)." 210 | (->CompletableFutureHandler)))) 211 | -------------------------------------------------------------------------------- /src/vault/client/mock.clj: -------------------------------------------------------------------------------- 1 | (ns vault.client.mock 2 | "A mock in-memory Vault client for local testing." 3 | (:require 4 | [clojure.edn :as edn] 5 | [clojure.java.io :as io] 6 | [clojure.string :as str] 7 | [vault.auth :as auth] 8 | [vault.client.flow :as f] 9 | [vault.client.proto :as proto])) 10 | 11 | 12 | ;; ## Mock Client 13 | 14 | ;; - `flow` 15 | ;; Control flow handler. 16 | ;; - `auth` 17 | ;; Atom containing the authentication state. 18 | ;; - `memory` 19 | ;; Mock memory storage. 20 | (defrecord MockClient 21 | [flow auth memory] 22 | 23 | proto/Client 24 | 25 | (auth-info 26 | [_] 27 | (auth/current auth)) 28 | 29 | 30 | (authenticate! 31 | [this auth-info] 32 | (let [auth-info (if (string? auth-info) 33 | {::auth/client-token auth-info} 34 | auth-info)] 35 | (when-not (and (map? auth-info) (::auth/client-token auth-info)) 36 | (throw (IllegalArgumentException. 37 | "Client authentication must be a map of information containing a client-token."))) 38 | (auth/set! auth auth-info) 39 | this))) 40 | 41 | 42 | ;; ## Constructors 43 | 44 | ;; Privatize automatic constructors. 45 | (alter-meta! #'->MockClient assoc :private true) 46 | (alter-meta! #'map->MockClient assoc :private true) 47 | 48 | 49 | (defn- load-fixtures 50 | "Helper method to load fixture data from a path. The path may resolve to a 51 | resource on the classpath, a file on the filesystem, or be `-` to specify no 52 | data." 53 | [path] 54 | (when (not= path "-") 55 | (some-> 56 | (or (io/resource path) 57 | (let [file (io/file path)] 58 | (when (.exists file) 59 | file))) 60 | (slurp) 61 | (edn/read-string)))) 62 | 63 | 64 | (defn- load-init 65 | "Load the initial data specified by the given value. Accepts a map of data 66 | directly, or a `mock:` scheme URN with a path to fixture data to load, or 67 | `mock:-` for an empty initial dataset." 68 | [init] 69 | (cond 70 | (map? init) 71 | init 72 | 73 | (str/starts-with? (str init) "mock:") 74 | (let [path (subs (str init) 5)] 75 | (or (load-fixtures path) {})) 76 | 77 | :else 78 | (throw (IllegalArgumentException. 79 | (str "Mock client must be constructed with a map of data or a URN with scheme 'mock': " 80 | (pr-str init)))))) 81 | 82 | 83 | (defn mock-client 84 | "Create a new mock Vault client. The `init` argument may either be a map of 85 | data to use for the internal state or a URI with the `mock:` scheme. A 86 | `mock:-` value indicates an empty state, or it may be a path to a resource on 87 | the classpath like `mock:path/to/data.edn`. 88 | 89 | Options: 90 | 91 | - `:flow` ([[vault.client.flow/Handler]]) 92 | 93 | Custom control flow handler to use with the client. Defaults to 94 | [[vault.client.flow/sync-handler]]." 95 | ([] 96 | (mock-client {})) 97 | ([init & {:as opts}] 98 | (let [data (load-init init)] 99 | (map->MockClient 100 | (merge {:flow f/sync-handler} 101 | opts 102 | {:auth (auth/new-state) 103 | :memory (atom data :validator map?)}))))) 104 | 105 | 106 | ;; ## Utility Functions 107 | 108 | (defn ^:no-doc success-response 109 | "Helper which uses the handler to generate a successful response." 110 | [client data] 111 | (let [handler (:flow client)] 112 | (f/call 113 | handler nil 114 | (fn success 115 | [state] 116 | (f/on-success! handler state nil data))))) 117 | 118 | 119 | (defn ^:no-doc error-response 120 | "Helper which uses the handler to generate an error response." 121 | [client ex] 122 | (let [handler (:flow client)] 123 | (f/call 124 | handler nil 125 | (fn error 126 | [state] 127 | (f/on-error! handler state nil ex))))) 128 | 129 | 130 | (defn ^:no-doc list-paths 131 | "Given a collection of path key strings and a target path, return a vector of 132 | path segments that are direct children of the target." 133 | [paths target] 134 | (let [depth (if (str/blank? target) 135 | 1 136 | (inc (count (str/split target #"/")))) 137 | prefix (if (str/blank? target) 138 | "" 139 | (str target "/"))] 140 | (->> paths 141 | (keep (fn check-path 142 | [path] 143 | (when (str/starts-with? path prefix) 144 | (let [parts (str/split path #"/")] 145 | (if (< depth (count parts)) 146 | (str (nth parts (dec depth)) "/") 147 | (last parts)))))) 148 | (distinct) 149 | (sort) 150 | (vec)))) 151 | 152 | 153 | (defn ^:no-doc update-secret! 154 | "Update the secret at the given path into the memory. If `update-fn` returns 155 | nil, the secret path will be removed. Calls `result-fn` to produce a result 156 | which is returned to the caller." 157 | ([client secret-path update-fn] 158 | (update-secret! client secret-path update-fn (constantly nil))) 159 | ([client secret-path update-fn result-fn] 160 | (try 161 | (-> (:memory client) 162 | (swap! 163 | (fn apply-update 164 | [secrets] 165 | (let [secret (update-fn (get-in secrets secret-path))] 166 | (if (nil? secret) 167 | (update-in secrets (butlast secret-path) dissoc (last secret-path)) 168 | (assoc-in secrets secret-path secret))))) 169 | (get-in secret-path) 170 | (result-fn) 171 | (as-> result 172 | (success-response client result))) 173 | (catch Exception ex 174 | (error-response client ex))))) 175 | -------------------------------------------------------------------------------- /src/vault/util.cljc: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc vault.util 2 | "Vault implementation utilities." 3 | (:require 4 | [clojure.string :as str] 5 | [clojure.walk :as walk]) 6 | (:import 7 | java.time.Instant 8 | java.util.Base64)) 9 | 10 | 11 | ;; ## Misc 12 | 13 | (defn update-some 14 | "Apply the function `f` to the map value at `k` and any additional `args`, 15 | only if `m` contains `k`. Returns the updated map." 16 | [m k f & args] 17 | (if-let [[k* v] (find m k)] 18 | (assoc m k* (apply f v args)) 19 | m)) 20 | 21 | 22 | (defn validate 23 | "Validate whether a map of data adheres to the validators set for the 24 | individual keys. Returns true if the map is valid, false otherwise. All keys 25 | are treated as optional, and any additional keys are not checked." 26 | [spec m] 27 | (and (map? m) 28 | (reduce-kv 29 | (fn check-key 30 | [_ k v] 31 | (if-let [pred (get spec k)] 32 | (if (pred v) 33 | true 34 | (reduced false)) 35 | true)) 36 | true 37 | m))) 38 | 39 | 40 | ;; ## Keywords 41 | 42 | (defn walk-keys 43 | "Update the provided data structure by calling `f` on each map key." 44 | [data f] 45 | (walk/postwalk 46 | (fn xform 47 | [x] 48 | (if (map? x) 49 | (into (empty x) 50 | (map (juxt (comp f key) val)) 51 | x) 52 | x)) 53 | data)) 54 | 55 | 56 | (defn keywordize-keys 57 | "Update the provided data structure by coercing all map keys to keywords." 58 | [data] 59 | (walk-keys data keyword)) 60 | 61 | 62 | (defn kebab-keyword 63 | "Converts underscores to hyphens in a string or unqualified keyword. Returns 64 | a simple kebab-case keyword." 65 | [k] 66 | (-> k name (str/replace "_" "-") keyword)) 67 | 68 | 69 | (defn kebabify-keys 70 | "Walk the provided data structure by transforming map keys to kebab-case 71 | keywords." 72 | [data] 73 | (walk-keys data kebab-keyword)) 74 | 75 | 76 | (defn kebabify-body-auth 77 | "Look up a map in the provided body under the `\"auth\"` key and kebabify 78 | it." 79 | [body] 80 | (kebabify-keys (get body "auth"))) 81 | 82 | 83 | (defn kebabify-body-data 84 | "Look up a map in the provided body under the `\"data\"` key and kebabify 85 | it." 86 | [body] 87 | (kebabify-keys (get body "data"))) 88 | 89 | 90 | (defn snake-str 91 | "Converts hyphens to underscores in a string or keyword. Returns a snake-case 92 | string." 93 | [k] 94 | (-> k name (str/replace "-" "_"))) 95 | 96 | 97 | (defn snakify-keys 98 | "Walk the provided data structure by transforming map keys to snake_case 99 | strings." 100 | [data] 101 | (walk-keys data snake-str)) 102 | 103 | 104 | (defn stringify-key 105 | "Convert a map key into a string, with some special treatment for keywords." 106 | [k] 107 | (if (keyword? k) 108 | (subs (str k) 1) 109 | (str k))) 110 | 111 | 112 | (defn stringify-keys 113 | "Walk the provided data structure to transform map keys to strings." 114 | [data] 115 | (walk-keys data stringify-key)) 116 | 117 | 118 | ;; ## Encoding 119 | 120 | (defn hex-encode 121 | "Encode an array of bytes to hex string." 122 | ^String 123 | [^bytes data] 124 | (str/join (map #(format "%02x" %) data))) 125 | 126 | 127 | (defn base64-encode 128 | "Encode the given data as base-64. If the input is a string, it is 129 | automatically coerced into UTF-8 bytes." 130 | [data] 131 | (.encodeToString 132 | (Base64/getEncoder) 133 | ^bytes 134 | (cond 135 | (bytes? data) 136 | data 137 | 138 | (string? data) 139 | (.getBytes ^String data "UTF-8") 140 | 141 | :else 142 | (throw (IllegalArgumentException. 143 | (str "Don't know how to base64-encode value with type: " 144 | (class data) " (expected a string or byte array)")))))) 145 | 146 | 147 | (defn base64-decode 148 | "Decode the given base-64 string into byte or string data." 149 | ([data] 150 | (base64-decode data false)) 151 | ([data as-string?] 152 | (when-not (string? data) 153 | (throw (IllegalArgumentException. 154 | (str "Don't know how to base64-decode value with type: " 155 | (class data) " (expected a string)")))) 156 | (let [bs (.decode (Base64/getDecoder) ^String data)] 157 | (if as-string? 158 | (String. bs "UTF-8") 159 | bs)))) 160 | 161 | 162 | ;; ## Paths 163 | 164 | (defn trim-path 165 | "Remove any leading and trailing slashes from a path string." 166 | [path] 167 | (str/replace path #"^/+|/+$" "")) 168 | 169 | 170 | (defn join-path 171 | "Join a number of path segments together with slashes, after trimming them." 172 | [& parts] 173 | (trim-path (str/join "/" (map trim-path parts)))) 174 | 175 | 176 | ;; ## Time 177 | 178 | (defn now 179 | "Returns the current time as an `Instant`." 180 | ^Instant 181 | [] 182 | (Instant/now)) 183 | 184 | 185 | (defn now-milli 186 | "Return the current time in epoch milliseconds." 187 | [] 188 | (.toEpochMilli (now))) 189 | 190 | 191 | (defmacro with-now 192 | "Evaluate the body of expressions with `now` bound to the provided 193 | instant. Mostly useful for rebinding in tests." 194 | [inst & body] 195 | `(with-redefs [now (constantly ~inst)] 196 | ~@body)) 197 | 198 | 199 | ;; ## Secret Protection 200 | 201 | #?(:bb nil 202 | :clj (deftype Veil 203 | [value])) 204 | 205 | 206 | (defn veil 207 | "Wrap the provided value in an opaque type which will not reveal its contents 208 | when printed. No effect in babashka." 209 | [x] 210 | #?(:bb x 211 | :clj (->Veil x))) 212 | 213 | 214 | (defn unveil 215 | "Unwrap the hidden value if `x` is veiled. Otherwise, returns `x` directly." 216 | [x] 217 | #?(:bb x 218 | :clj (if (instance? Veil x) 219 | (.-value ^Veil x) 220 | x))) 221 | -------------------------------------------------------------------------------- /test/vault/secret/transit_test.clj: -------------------------------------------------------------------------------- 1 | (ns vault.secret.transit-test 2 | (:require 3 | [clojure.test :refer [deftest testing is]] 4 | [vault.integration :refer [with-dev-server cli]] 5 | [vault.secret.transit :as transit])) 6 | 7 | 8 | (deftest ^:integration http-api 9 | (with-dev-server 10 | (cli "secrets" "enable" "transit") 11 | (testing "on missing keys" 12 | (testing "read-key" 13 | (is (thrown-with-msg? Exception #"not found" 14 | (transit/read-key client "missing")) 15 | "should throw not-found error")) 16 | (testing "rotate-key!" 17 | (is (thrown-with-msg? Exception #"not found" 18 | (transit/rotate-key! client "missing")) 19 | "should throw not-found error")) 20 | (testing "update-key-configuration!" 21 | (is (thrown-with-msg? Exception #"no existing key .+ found" 22 | (transit/update-key-configuration! 23 | client "missing" 24 | {:min-encryption-version 2})) 25 | "should throw not-found error")) 26 | ;; NOTE: not covering encrypt-data, since it automatically creates a key 27 | (testing "decrypt-data!" 28 | (is (thrown-with-msg? Exception #"encryption key not found" 29 | (transit/decrypt-data! 30 | client "missing" 31 | "vault:v1:SSBhbSBMcnJyLCBydWxlciBvZiB0aGUgcGxhbmV0IE9taWNyb24gUGVyc2VpIDgh")) 32 | "should throw not-found error"))) 33 | (cli "write" "-force" "transit/keys/test") 34 | (testing "read-key" 35 | (let [key-info (transit/read-key client "test")] 36 | (is (= "test" (:name key-info))) 37 | (is (= "aes256-gcm96" (:type key-info))) 38 | (is (true? (:supports-encryption key-info))) 39 | (is (true? (:supports-decryption key-info))) 40 | (is (false? (:supports-signing key-info))) 41 | (is (= 0 (:min-available-version key-info))) 42 | (is (= 0 (:min-encryption-version key-info))) 43 | (is (= 1 (:min-decryption-version key-info))) 44 | (is (= 1 (:latest-version key-info))) 45 | (is (map? (:keys key-info))) 46 | (is (= 1 (count (:keys key-info)))) 47 | (let [[version created-at] (first (:keys key-info))] 48 | (is (= 1 version)) 49 | (is (inst? created-at))))) 50 | (testing "single mode" 51 | (let [plaintext "I am Lrrr, ruler of the planet Omicron Persei 8!" 52 | ciphertext (atom nil)] 53 | (testing "encrypt-data!" 54 | (let [result (transit/encrypt-data! client "test" plaintext)] 55 | (is (string? (:ciphertext result))) 56 | (is (= 1 (:key-version result))) 57 | (reset! ciphertext (:ciphertext result)))) 58 | (testing "decrypt-data!" 59 | (testing "to bytes" 60 | (let [result (transit/decrypt-data! client "test" @ciphertext)] 61 | (is (bytes? (:plaintext result))) 62 | (is (= plaintext (String. ^bytes (:plaintext result) "UTF-8"))))) 63 | (testing "to string" 64 | (let [result (transit/decrypt-data! client "test" @ciphertext {:as-string true})] 65 | (is (string? (:plaintext result))) 66 | (is (= plaintext (:plaintext result)))))))) 67 | (testing "batch mode" 68 | (let [inputs [{:plaintext "Good news, everyone!" 69 | :reference "Professor"} 70 | {:plaintext "Bite my shiny metal ass" 71 | :reference "Bender"} 72 | {:plaintext "Oh lord" 73 | :reference "Leela"}] 74 | batch (atom nil)] 75 | (testing "encrypt-data!" 76 | (let [result (transit/encrypt-data! client "test" inputs)] 77 | (is (vector? result)) 78 | (is (= 3 (count result))) 79 | (is (= ["Professor" "Bender" "Leela"] (map :reference result))) 80 | (is (every? :ciphertext result)) 81 | (is (every? #(= 1 (:key-version %)) result)) 82 | (reset! batch result))) 83 | (testing "decrypt-data!" 84 | (testing "to bytes" 85 | (let [result (transit/decrypt-data! client "test" @batch)] 86 | (is (vector? result)) 87 | (is (= 3 (count result))) 88 | (is (= ["Professor" "Bender" "Leela"] (map :reference result))) 89 | (is (every? (comp bytes? :plaintext) result)) 90 | (is (= "Good news, everyone!" (String. (:plaintext (first result)) "UTF-8"))))) 91 | (testing "to string" 92 | (let [result (transit/decrypt-data! client "test" @batch {:as-string true})] 93 | (is (vector? result)) 94 | (is (= inputs result))))))) 95 | (testing "rotation" 96 | (testing "rotate-key!" 97 | (let [key-info (transit/rotate-key! client "test")] 98 | (is (= "test" (:name key-info))) 99 | (is (= "aes256-gcm96" (:type key-info))) 100 | (is (= 0 (:min-available-version key-info))) 101 | (is (= 0 (:min-encryption-version key-info))) 102 | (is (= 1 (:min-decryption-version key-info))) 103 | (is (= 2 (:latest-version key-info))) 104 | (is (map? (:keys key-info))) 105 | (is (= #{1 2} (set (keys (:keys key-info))))) 106 | (is (inst? (get-in key-info [:keys 2]))))) 107 | (testing "update-key-configuration!" 108 | (let [key-info (transit/update-key-configuration! 109 | client "test" 110 | {:min-encryption-version 2})] 111 | (is (= "test" (:name key-info))) 112 | (is (= 0 (:min-available-version key-info))) 113 | (is (= 2 (:min-encryption-version key-info))) 114 | (is (thrown-with-msg? Exception #"requested version for encryption is less than the minimum encryption key version" 115 | (transit/encrypt-data! client "test" "gimme the old one" {:key-version 1})))))))) 116 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | # Common executor configuration 4 | executors: 5 | clojure: 6 | docker: 7 | - image: cimg/clojure:1.11-openjdk-11.0 8 | working_directory: ~/repo 9 | 10 | 11 | # Reusable job steps 12 | commands: 13 | install-vault: 14 | description: "Install the Vault CLI" 15 | steps: 16 | - run: 17 | name: Install vault 18 | environment: 19 | VAULT_VERSION: 1.14.0 20 | command: | 21 | wget https://releases.hashicorp.com/vault/${VAULT_VERSION}/vault_${VAULT_VERSION}_linux_amd64.zip 22 | unzip vault_${VAULT_VERSION}_linux_amd64.zip 23 | sudo mv vault /usr/local/bin/vault 24 | 25 | 26 | # Job definitions 27 | jobs: 28 | style: 29 | executor: clojure 30 | steps: 31 | - checkout 32 | - run: 33 | name: Install cljstyle 34 | environment: 35 | CLJSTYLE_VERSION: 0.15.0 36 | command: | 37 | wget https://github.com/greglook/cljstyle/releases/download/${CLJSTYLE_VERSION}/cljstyle_${CLJSTYLE_VERSION}_linux.zip 38 | unzip cljstyle_${CLJSTYLE_VERSION}_linux.zip 39 | - run: 40 | name: Check source formatting 41 | command: "./cljstyle check --report" 42 | 43 | lint: 44 | executor: clojure 45 | steps: 46 | - checkout 47 | - run: 48 | name: Install clj-kondo 49 | environment: 50 | CLJ_KONDO_VERSION: 2022.11.02 51 | command: | 52 | wget https://github.com/clj-kondo/clj-kondo/releases/download/v${CLJ_KONDO_VERSION}/clj-kondo-${CLJ_KONDO_VERSION}-linux-amd64.zip 53 | unzip clj-kondo-${CLJ_KONDO_VERSION}-linux-amd64.zip 54 | - run: 55 | name: Lint source code 56 | command: "./clj-kondo --lint src test" 57 | 58 | test: 59 | executor: clojure 60 | steps: 61 | - checkout 62 | - restore_cache: 63 | keys: 64 | - v1-test-{{ checksum "deps.edn" }} 65 | - v1-test- 66 | - run: bin/test check 67 | - run: bin/test unit 68 | - save_cache: 69 | key: v1-test-{{ checksum "deps.edn" }} 70 | paths: 71 | - ~/.m2 72 | 73 | integration: 74 | executor: clojure 75 | steps: 76 | - checkout 77 | - install-vault 78 | - restore_cache: 79 | keys: 80 | - v1-test-{{ checksum "deps.edn" }} 81 | - v1-test- 82 | - run: bin/test integration 83 | 84 | coverage: 85 | executor: clojure 86 | steps: 87 | - checkout 88 | - install-vault 89 | - restore_cache: 90 | keys: 91 | - v1-coverage-{{ checksum "deps.edn" }} 92 | - v1-coverage- 93 | - v1-test- 94 | - run: 95 | name: Generate test coverage 96 | command: bin/test coverage 97 | - save_cache: 98 | paths: 99 | - ~/.m2 100 | key: v1-coverage-{{ checksum "deps.edn" }} 101 | - store_artifacts: 102 | path: target/coverage 103 | destination: coverage 104 | - run: 105 | name: Install codecov 106 | command: | 107 | sudo apt-get update && sudo apt-get install gpg 108 | curl https://keybase.io/codecovsecurity/pgp_keys.asc | gpg --no-default-keyring --keyring trustedkeys.gpg --import 109 | curl -Os https://uploader.codecov.io/latest/linux/codecov 110 | curl -Os https://uploader.codecov.io/latest/linux/codecov.SHA256SUM 111 | curl -Os https://uploader.codecov.io/latest/linux/codecov.SHA256SUM.sig 112 | gpgv codecov.SHA256SUM.sig codecov.SHA256SUM 113 | shasum -a 256 -c codecov.SHA256SUM 114 | chmod +x codecov 115 | - run: 116 | name: Publish coverage report 117 | command: './codecov -f target/coverage/codecov.json' 118 | 119 | auth-ldap: 120 | docker: 121 | - image: cimg/clojure:1.11-openjdk-11.0 122 | - image: osixia/openldap:1.4.0 123 | environment: 124 | LDAP_DOMAIN: test.com 125 | LDAP_ORGANISATION: vault-clj 126 | LDAP_ADMIN_PASSWORD: ldap-admin 127 | working_directory: ~/repo 128 | steps: 129 | - checkout 130 | - install-vault 131 | - run: 132 | name: Install LDAP utils 133 | command: sudo apt-get update && sudo apt-get install -y ldap-utils 134 | - run: 135 | name: Configure LDAP server 136 | command: | 137 | while ! nc -vz localhost 389 2>/dev/null; do sleep 1; done 138 | ldapadd -cxD "cn=admin,dc=test,dc=com" -w ldap-admin -f docker/ldap/sample-organization.ldif 139 | - restore_cache: 140 | keys: 141 | - v1-test-{{ checksum "deps.edn" }} 142 | - v1-test- 143 | - run: 144 | name: Test LDAP auth 145 | command: bin/test --focus vault.auth.ldap-test 146 | environment: 147 | VAULT_LDAP_DOMAIN: dc=test,dc=com 148 | VAULT_LDAP_ADMIN_PASS: ldap-admin 149 | VAULT_LDAP_LOGIN_USER: alice 150 | VAULT_LDAP_LOGIN_PASS: hunter2 151 | 152 | secret-database: 153 | docker: 154 | - image: cimg/clojure:1.11-openjdk-11.0 155 | - image: cimg/postgres:14.5 156 | environment: 157 | POSTGRES_USER: postgres 158 | POSTGRES_PASSWORD: hunter2 159 | working_directory: ~/repo 160 | steps: 161 | - checkout 162 | - install-vault 163 | - restore_cache: 164 | keys: 165 | - v1-test-{{ checksum "deps.edn" }} 166 | - v1-test- 167 | - run: 168 | name: Test database credentials 169 | command: bin/test --focus vault.secret.database-test 170 | environment: 171 | VAULT_POSTGRES_ADMIN_USER: postgres 172 | VAULT_POSTGRES_ADMIN_PASS: hunter2 173 | VAULT_POSTGRES_ROLE: postgres 174 | 175 | 176 | # Workflow definitions 177 | workflows: 178 | version: 2 179 | test: 180 | jobs: 181 | - style 182 | - lint 183 | - test 184 | - integration 185 | - coverage: 186 | requires: 187 | - test 188 | - integration 189 | - auth-ldap: 190 | requires: 191 | - test 192 | - secret-database: 193 | requires: 194 | - test 195 | -------------------------------------------------------------------------------- /doc/upgrading-1x.md: -------------------------------------------------------------------------------- 1 | # Upgrading from 1.x 2 | 3 | If you're upgrading from the 1.x major version, there are a number of 4 | differences to account for. 5 | 6 | 7 | ## Build and Dependencies 8 | 9 | The coordinate for the library has changed from `amperity/vault-clj` to 10 | `com.amperity/vault-clj`, in keeping with Clojars' new domain verification 11 | requirements. Additionally, the dependencies used by the library are much 12 | lighter weight now: 13 | 14 | - Use `org.clojure/data.json` for JSON serialization now instead of `cheshire`, 15 | avoiding messy Jackson dependencies. 16 | - Dropped dependency on `com.stuartsierra/component`. 17 | - Dropped dependency on `envoy`. 18 | 19 | 20 | ## Client Protocols 21 | 22 | Many of the protocols and methods previously in `vault.core` have moved to 23 | backend-specific namespaces. For example: 24 | 25 | - `TokenManager` is now `vault.auth.token/API` 26 | - `LeaseManager` is now `vault.sys.leases/API` 27 | - `WrappingClient` is now `vault.sys.wrapping/API` 28 | - `SecretEngine` is replaced by engine-specific protocols in `vault.secret.*` 29 | 30 | The two previously implemented secrets engines have moved slightly: 31 | 32 | - `vault.secrets.kvv1` is now `vault.secret.kv.v1` 33 | - `vault.secrets.kvv2` is now `vault.secret.kv.v2` 34 | 35 | For the KV secrets engines, previously a `list-secrets` call would return a 36 | vector of the keys at that prefix directly. Now, these methods return a map 37 | with a `:keys` vector entry if there are secrets present, matching the actual 38 | API response shape. 39 | 40 | ### Mounts 41 | 42 | In 1.x, reading a secret from a customized mount required embedding the mount 43 | prefix in the secret path at read time. Now, each secret engine provides a 44 | `with-mount` method which returns an updated client which will perform reads 45 | against the specified mount. This lets customization happen at configuration 46 | time and decouples the code using the client from knowledge of the mount path. 47 | 48 | 49 | ## Authentication 50 | 51 | Rather than a single multimethod in `vault.authenticate`, client authentication 52 | is now performed by calling method-specific protocols. For example, if you were 53 | previously using the `userpass` method: 54 | 55 | ```clojure 56 | (require '[vault.core :as vault]) 57 | 58 | (def client (vault/new-client "...")) 59 | 60 | (vault/authenticate! client :userpass {:username "bob", :password "hunter2"} 61 | ``` 62 | 63 | This now looks like: 64 | 65 | ```clojure 66 | (require '[vault.client :as vault] 67 | '[vault.auth.userpass :as userpass]) 68 | 69 | (def client (vault/new-client "...")) 70 | 71 | (userpass/login client "bob" "hunter2") 72 | ``` 73 | 74 | 75 | ## Renewal and Rotation 76 | 77 | In 1.x, the client would renew and rotate secrets as they approached expiry. 78 | This was done on a single background thread running as part of the client 79 | state. If consumers needed to react to lifecycle events, they could register a 80 | "lease watch", which would be invoked when the lease changed. While this worked 81 | as a hook for rotated credentials to be updated in whatever system was using 82 | them, it was a bit clunky and had some problems: 83 | - It didn't handle more nuanced outcomes like failures well. Even expiration 84 | just called the watch function with `nil`. 85 | - The calling code also had to know up front what secret path to register the 86 | watch for, coupling it to the underlying Vault API. 87 | - The watches ran on the same timer thread, so a slow callback could block 88 | other watches and even future lease maintenance. 89 | 90 | In 2.x, things are a bit different. Instead of a single thread, the client now 91 | supports setting a `maintenance-executor` and a `callback-executor`. The 92 | maintenance executor is responsible for running a periodic task to perform the 93 | interactions with Vault, while any callbacks are passed to the callback 94 | executor. If not provided, callbacks run on the same thread pool as a regular 95 | Clojure `future`. This gives consumers more control over how these tasks are 96 | run, as well as preventing the periodic task from getting blocked. 97 | 98 | Instead of a separate method to register callbacks, users can pass a set of 99 | callback functions to the method used to read the secret. For example, in 100 | `vault.secret.database/generate-credentials!` a caller can specify `:on-renew`, 101 | `:on-rotate`, and `:on-error` functions to handle each outcome. Generally, 102 | the rotation callback would replace the previous use of a lease watcher. 103 | 104 | 105 | ## Component Lifecycle 106 | 107 | For flexibility, the library no longer depends on `com.stuartsierra/component` 108 | and clients no longer implement the `Lifecycle` protocol. Instead, the 109 | lifecycle methods are available as the regular functions `vault.client/start` 110 | and `vault.client/stop`. 111 | 112 | If you want to continue using `component` as a dependency injection library, 113 | you can use the following code to reestablish the previous behavior: 114 | 115 | ```clojure 116 | (require '[vault.client :as vault] 117 | '[com.stuartsierra.component :as component]) 118 | 119 | 120 | (extend vault.client.http.HTTPClient 121 | 122 | component/Lifecycle 123 | 124 | {:start vault/start 125 | :stop vault/stop}) 126 | ``` 127 | 128 | 129 | ## Environment Resolution 130 | 131 | The `vault.env` environment variable resolution code has been removed to 132 | decouple the library from `envoy`. This can be replicated locally in your 133 | project with code like the following: 134 | 135 | ```clojure 136 | (require '[clojure.string :as str]) 137 | 138 | 139 | (defn secret-uri? 140 | [s] 141 | (and (string? s) (str/starts-with? s "vault:"))) 142 | 143 | 144 | (defn resolve-uri! 145 | [client vault-uri] 146 | (let [[path attr] (str/split (subs vault-uri 6) #"#") 147 | secret (kv/read-secret client path) 148 | attr (or (keyword attr) :data) 149 | value (get secret attr)] 150 | (when (nil? value) 151 | (throw (ex-info (str "No value for secret " vault-uri) 152 | {:path path, :attr attr}))) 153 | value)) 154 | 155 | 156 | (defn resolve-env-secrets! 157 | [client env] 158 | (into {} 159 | (map (fn resolve-var 160 | [[k v]] 161 | (if (secret-uri? v) 162 | [k (resolve-uri! client v)] 163 | [k v]))) 164 | env)) 165 | ``` 166 | 167 | 168 | ## Misc 169 | 170 | The client no longer throws an error when you make calls without 171 | authentication, to support vault agent usage. 172 | [#63](https://github.com/amperity/vault-clj/issues/63) 173 | -------------------------------------------------------------------------------- /test/vault/auth/token_test.clj: -------------------------------------------------------------------------------- 1 | (ns vault.auth.token-test 2 | (:require 3 | [clojure.test :refer [is testing deftest]] 4 | [vault.auth.token :as token] 5 | [vault.client.mock :refer [mock-client]] 6 | [vault.integration :refer [with-dev-server cli test-client]])) 7 | 8 | 9 | (deftest mock-api 10 | (let [client (mock-client)] 11 | (testing "lookup-token" 12 | (is (thrown-with-msg? Exception #"bad token" 13 | (token/lookup-token client {:token "foo"}))) 14 | (let [info (token/lookup-token client {:token "r00t"})] 15 | (is (= "r00t" (:id info))) 16 | (is (pos-int? (:creation-time info))) 17 | (is (= info (token/lookup-token client {})) 18 | "self lookup should return same info") 19 | (is (= (assoc info :id "") 20 | (token/lookup-token client {:accessor (:accessor info)})) 21 | "accessor lookup should return info without id"))))) 22 | 23 | 24 | (deftest ^:integration http-api 25 | (with-dev-server 26 | (cli "write" "auth/token/roles/test" "renewable=false" "orphan=true" "token_explicit_max_ttl=5m") 27 | (let [tokens (atom {})] 28 | (testing "create-token!" 29 | (testing "directly" 30 | (let [auth (token/create-token! 31 | client 32 | {:meta {:foo "bar"} 33 | :policies ["default"]})] 34 | (swap! tokens assoc :default auth) 35 | (is (string? (:accessor auth))) 36 | (is (string? (:client-token auth))) 37 | (is (pos-int? (:lease-duration auth))) 38 | (is (= {:foo "bar"} (:metadata auth))) 39 | (is (= ["default"] (:policies auth))) 40 | (is (false? (:orphan auth))) 41 | (is (true? (:renewable auth))))) 42 | (testing "as orphan" 43 | (let [auth (token/create-orphan-token! 44 | client 45 | {:meta {:abc "def"} 46 | :policies ["default"] 47 | :renewable false})] 48 | (swap! tokens assoc :orphan auth) 49 | (is (string? (:accessor auth))) 50 | (is (string? (:client-token auth))) 51 | (is (pos-int? (:lease-duration auth))) 52 | (is (= {:abc "def"} (:metadata auth))) 53 | (is (= ["default"] (:policies auth))) 54 | (is (true? (:orphan auth))) 55 | (is (false? (:renewable auth))))) 56 | (testing "with role" 57 | (let [auth (token/create-role-token! 58 | client 59 | "test" 60 | {:policies ["default"] 61 | :renewable false})] 62 | (swap! tokens assoc :role auth) 63 | (is (string? (:accessor auth))) 64 | (is (string? (:client-token auth))) 65 | (is (= 300 (:lease-duration auth))) 66 | (is (= ["default"] (:policies auth))) 67 | (is (true? (:orphan auth))) 68 | (is (false? (:renewable auth)))))) 69 | (testing "lookup-token" 70 | (testing "self" 71 | (let [auth (:default @tokens) 72 | client (test-client (:client-token auth)) 73 | info (token/lookup-token client {})] 74 | (is (map? info)) 75 | (is (= (:client-token auth) (:id info))) 76 | (is (= (:accessor auth) (:accessor info))) 77 | (is (= (:policies auth) (:policies info))) 78 | (is (<= (dec (:lease-duration auth)) (:ttl info))) 79 | (is (= (:metadata auth) (:meta info))))) 80 | (testing "with token" 81 | (let [auth (:orphan @tokens) 82 | info (token/lookup-token client {:token (:client-token auth)})] 83 | (is (map? info)) 84 | (is (= (:client-token auth) (:id info))) 85 | (is (= (:accessor auth) (:accessor info))) 86 | (is (= (:policies auth) (:policies info))) 87 | (is (<= (dec (:lease-duration auth)) (:ttl info))) 88 | (is (= (:metadata auth) (:meta info))))) 89 | (testing "with accessor" 90 | (let [auth (:role @tokens) 91 | info (token/lookup-token client {:accessor (:accessor auth)})] 92 | (is (map? info)) 93 | (is (= "" (:id info))) 94 | (is (= (:accessor auth) (:accessor info))) 95 | (is (= (:policies auth) (:policies info))) 96 | (is (<= (dec (:lease-duration auth)) (:ttl info))) 97 | (is (= (:metadata auth) (:meta info)))))) 98 | (testing "renew-token!" 99 | (testing "self" 100 | (let [auth (:default @tokens) 101 | client (test-client (:client-token auth)) 102 | info (token/renew-token! client {})] 103 | (is (= (dissoc auth :lease-duration) 104 | (dissoc info :lease-duration))))) 105 | (testing "with token" 106 | (let [auth (:default @tokens) 107 | info (token/renew-token! client {:token (:client-token auth)})] 108 | (is (= (dissoc auth :lease-duration) 109 | (dissoc info :lease-duration))))) 110 | (testing "with accessor" 111 | (let [auth (:default @tokens) 112 | info (token/renew-token! client {:accessor (:accessor auth)})] 113 | (is (= (-> auth 114 | (assoc :client-token "") 115 | (dissoc :lease-duration)) 116 | (dissoc info :lease-duration)))))) 117 | (testing "revoke-token!" 118 | (testing "self" 119 | (let [auth (:role @tokens) 120 | result (token/revoke-token! 121 | (test-client (:client-token auth)) 122 | {})] 123 | (is (nil? result)) 124 | (is (thrown-with-msg? Exception #"bad token" 125 | (token/lookup-token client {:token (:client-token auth)}))))) 126 | (testing "with token" 127 | (let [auth (:orphan @tokens) 128 | result (token/revoke-token! client {:token (:client-token auth)})] 129 | (is (nil? result)) 130 | (is (thrown-with-msg? Exception #"bad token" 131 | (token/lookup-token client {:token (:client-token auth)}))))) 132 | (testing "with accessor" 133 | (let [auth (:default @tokens) 134 | result (token/revoke-token! client {:accessor (:accessor auth)})] 135 | (is (nil? result)) 136 | (is (thrown-with-msg? Exception #"bad token" 137 | (token/lookup-token client {:token (:client-token auth)}))))))))) 138 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | vault-clj 2 | ========= 3 | 4 | [![CircleCI](https://dl.circleci.com/status-badge/img/gh/amperity/vault-clj/tree/main.svg?style=shield)](https://dl.circleci.com/status-badge/redirect/gh/amperity/vault-clj/tree/main) 5 | [![codecov](https://codecov.io/gh/amperity/vault-clj/branch/main/graph/badge.svg)](https://codecov.io/gh/amperity/vault-clj) 6 | [![Clojars Project](https://img.shields.io/clojars/v/com.amperity/vault-clj.svg)](https://clojars.org/com.amperity/vault-clj) 7 | [![cljdoc](https://cljdoc.org/badge/com.amperity/vault-clj)](https://cljdoc.org/d/com.amperity/vault-clj/CURRENT) 8 | 9 | A Clojure library for interacting with the Hashicorp [Vault](https://vaultproject.io/) 10 | secret management system. Most of the non-administrative API is implemented, 11 | including the token authentication backend. Library releases are published on Clojars. 12 | 13 | 14 | ## Usage 15 | 16 | Using `vault-clj` involves first creating a client, then calling the API 17 | protocols you want to interact with on it. As a user, you'll most likely 18 | be utilizing a few high-level namespace groups: 19 | 20 | - `vault.client` - The main public namespace for creating Vault client objects. 21 | - `vault.auth.*` - [Authentication methods](https://developer.hashicorp.com/vault/api-docs/auth) such as token, approle, github, etc. 22 | - `vault.secret.*` - [Secrets engines](https://developer.hashicorp.com/vault/api-docs/secret) such as kv, database, transit, etc. 23 | - `vault.sys.*` - [System backend](https://developer.hashicorp.com/vault/api-docs/system) interfaces such as health, leases, wrapping, etc. 24 | 25 | ### Client Construction 26 | 27 | Let's start by creating a new client, assuming a locally-running development server: 28 | 29 | ```clojure 30 | ;; Pull in namespace 31 | => (require '[vault.client :as vault]) 32 | 33 | ;; If VAULT_ADDR is set we could use `config-client` to also automatically 34 | ;; authenticate with VAULT_TOKEN or the ~/.vault-token file. 35 | => (def client (vault/new-client "http://localhost:8200")) 36 | 37 | ;; Unless your process is very short-lived, you'll probably want to 'start' the 38 | ;; client to initiate background maintenance and callback tasks. Typically this 39 | ;; and 'stop' happen as part of a dependency-injection system. 40 | => (alter-var-root #'client vault/start) 41 | ``` 42 | 43 | ### Authentication 44 | 45 | Vault supports a number of authentication methods for obtaining an access 46 | token. The most basic mechanism is to directly set a token on the client, but 47 | many options are available. 48 | 49 | ```clojure 50 | ;; For simplicity, we can authenticate the client using a fixed token: 51 | => (vault/authenticate! client "t0p-53cr3t") 52 | 53 | ;; If we wanted to utilize approle for programmatic auth: 54 | => (require '[vault.auth.approle :as approle]) 55 | 56 | => (approle/login client "my-cool-role" (System/getenv "VAULT_ROLE_SECRET")) 57 | ``` 58 | 59 | See the individual auth method namespace docs for information on the method you 60 | want to use. 61 | 62 | ### Secrets Engines 63 | 64 | Now that we've got an authenticated client to work with, we can start 65 | interacting with the server. 66 | 67 | ```clojure 68 | ;; KVv2 is enabled by default as the basic engine for storing static secrets. 69 | => (require '[vault.secret.kv.v2 :as kv]) 70 | 71 | ;; Initially, there are no secrets: 72 | => (kv/list-secrets client "") 73 | nil 74 | 75 | ;; Trying to read a secret that doesn't exist throws an exception by default: 76 | => (kv/read-secret client "not/here") 77 | ;; Execution error (ExceptionInfo) at vault.secret.kv.v2/ex-not-found (v2.clj:178). 78 | ;; No kv-v2 secret found at secret:not/here 79 | 80 | ;; You can provide an explicit value to use for missing secrets instead: 81 | => (kv/read-secret client "not/here" {:not-found :missing}) 82 | :missing 83 | 84 | ;; Let's store a new secret, which returns information about the result: 85 | => (kv/write-secret! client "foo/bar" {:alpha "abc", :num 123, :kw :xyz}) 86 | {:created-time #inst "2023-09-14T06:57:25.330167Z" 87 | :destroyed false 88 | :version 1} 89 | 90 | ;; Client responses contain metadata about the underlying HTTP request: 91 | => (meta *1) 92 | {:vault.client/method :post 93 | :vault.client/path "secret/data/foo/bar" 94 | :vault.client/status 200 95 | :vault.client/request-id "77f12c57-cb10-f0a7-939d-c05a7c7a1bde" 96 | :vault.client/headers {:cache-control "no-store" 97 | :content-length "276" 98 | :content-type "application/json" 99 | :date "Thu, 14 Sep 2023 06:57:25 GMT" 100 | :strict-transport-security "max-age=31536000; includeSubDomains"} 101 | :vault.secret.kv.v2/mount "secret" 102 | :vault.secret.kv.v2/path "foo/bar"} 103 | 104 | ;; Now we can see our secret in the listing: 105 | => (kv/list-secrets client "") 106 | {:keys ["foo/"]} 107 | 108 | => (kv/list-secrets client "foo/") 109 | {:keys ["bar"]} 110 | 111 | ;; Let's read the secret data back. There's one gotcha here, which is that 112 | ;; Vault serializes secret data to JSON, so our keyword value has been 113 | ;; stringified during the round-trip: 114 | => (kv/read-secret client "foo/bar") 115 | {:alpha "abc", :kw "xyz", :num 123} 116 | 117 | ;; As before, the response has metadata about the client call and this time, 118 | ;; the secret itself: 119 | {:vault.client/method :get 120 | :vault.client/path "secret/data/foo/bar" 121 | :vault.client/status 200 122 | :vault.client/request-id "c664637a-6042-d694-18e4-f49de23431c3" 123 | :vault.client/headers {:cache-control "no-store" 124 | :content-length "333" 125 | :content-type "application/json" 126 | :date "Thu, 14 Sep 2023 06:59:07 GMT" 127 | :strict-transport-security "max-age=31536000; includeSubDomains"} 128 | :vault.secret.kv.v2/created-time #inst "2023-09-14T06:57:25.330167Z" 129 | :vault.secret.kv.v2/custom-metadata nil 130 | :vault.secret.kv.v2/destroyed false 131 | :vault.secret.kv.v2/mount "secret" 132 | :vault.secret.kv.v2/path "foo/bar" 133 | :vault.secret.kv.v2/version 1} 134 | ``` 135 | 136 | Each secrets engine defines its own protocol, so refer to their documentation 137 | for how to interact with them. The goal is for these protocols to adhere 138 | closely to the documented Vault APIs in structure and arguments. 139 | 140 | ### Babashka 141 | 142 | The library is compatible with [babashka](https://babashka.org/) for lightweight 143 | Vault integration. See the `bb.edn` file for an example to get you started. It 144 | implements a basic task that reads a secret from Vault and prints it. 145 | 146 | ```shell 147 | export VAULT_ADDR=your-vault-server-path 148 | export VAULT_AUTH=token 149 | export VAULT_TOKEN=token-value 150 | 151 | bb vault-get 152 | ``` 153 | 154 | 155 | ## Local Development 156 | 157 | The client code can all be exercised in a REPL against a local development 158 | Vault server. In most cases this is as simple as running `bin/server` and 159 | `bin/repl`. See the [development doc](doc/development.md) for more detailed 160 | instructions. 161 | 162 | 163 | ## License 164 | 165 | Copyright © 2016-2023 Amperity, Inc 166 | 167 | Distributed under the Apache License, Version 2.0. See the LICENSE file 168 | for more information. 169 | -------------------------------------------------------------------------------- /src/vault/client.cljc: -------------------------------------------------------------------------------- 1 | (ns vault.client 2 | "Main Vault client namespace. Contains functions for generic client 3 | operations, constructing new clients, and using clients as components in a 4 | larger system." 5 | (:require 6 | [clojure.java.io :as io] 7 | [clojure.string :as str] 8 | [clojure.tools.logging :as log] 9 | [vault.auth :as auth] 10 | [vault.auth.token :as auth.token] 11 | [vault.client.flow :as f] 12 | [vault.client.http :as http] 13 | [vault.client.mock :as mock] 14 | [vault.client.proto :as proto] 15 | [vault.lease :as lease] 16 | [vault.sys.leases :as sys.leases] 17 | [vault.sys.wrapping :as sys.wrapping]) 18 | #?(:bb 19 | (:import 20 | java.net.URI) 21 | :clj 22 | (:import 23 | java.net.URI 24 | (java.util.concurrent 25 | ExecutorService 26 | ScheduledExecutorService 27 | ScheduledThreadPoolExecutor 28 | TimeUnit)))) 29 | 30 | 31 | ;; ## Protocol Methods 32 | 33 | (defn client? 34 | "True if the value satisfies the client protocol." 35 | [x] 36 | (satisfies? proto/Client x)) 37 | 38 | 39 | (defn auth-info 40 | "Return the client's current auth information, a map containing the 41 | `:vault.auth/token` and other metadata keys from the [[vault.auth]] 42 | namespace. Returns nil if the client is unauthenticated." 43 | [client] 44 | (proto/auth-info client)) 45 | 46 | 47 | (defn authenticate! 48 | "Manually authenticate the client by providing a map of auth information 49 | containing a `:vault.auth/token`. As a shorthand, a Vault token string may 50 | be provided directly. Returns the client." 51 | [client auth-info] 52 | (proto/authenticate! client auth-info)) 53 | 54 | 55 | (defn authenticate-wrapped! 56 | "Authenticate the client by unwrapping a limited-use auth token. Returns the 57 | client." 58 | [client wrap-token] 59 | (proto/authenticate! client wrap-token) 60 | (let [result (f/call-sync sys.wrapping/unwrap client)] 61 | (proto/authenticate! client (:client-token result)) 62 | (auth.token/resolve-auth! client) 63 | client)) 64 | 65 | 66 | ;; ## Maintenance Task 67 | 68 | (defn- maintenance-task 69 | "Construct a new runnable task to perform client authentication and lease 70 | maintenance work." 71 | ^Runnable 72 | [client] 73 | (letfn [(renew-auth-token! 74 | [] 75 | (f/call-sync auth.token/renew-token! client {})) 76 | 77 | (renew-lease! 78 | [lease] 79 | (f/call-sync 80 | sys.leases/renew-lease! 81 | client 82 | (::lease/id lease) 83 | (::lease/renew-increment lease)))] 84 | (fn tick 85 | [] 86 | (try 87 | (auth/maintain! client renew-auth-token!) 88 | (lease/maintain! client renew-lease!) 89 | (catch InterruptedException _ 90 | nil) 91 | (catch Exception ex 92 | (log/error ex "Unhandled error while running maintenance task!")))))) 93 | 94 | 95 | #?(:bb nil 96 | :clj 97 | (defn start 98 | "Start the Vault client, returning an updated component with a periodic 99 | maintenance task. See [[new-client]] for options which control maintenance 100 | behavior." 101 | [client] 102 | (when-let [task (:maintenance-task client)] 103 | (future-cancel task)) 104 | (let [period (:maintenance-period client 10) 105 | executor (or (:maintenance-executor client) 106 | (ScheduledThreadPoolExecutor. 1))] 107 | (log/info "Scheduling vault maintenance task to run every" period "seconds") 108 | (assoc client 109 | :maintenance-executor executor 110 | :maintenance-task (.scheduleAtFixedRate 111 | ^ScheduledExecutorService executor 112 | (maintenance-task client) 113 | period period TimeUnit/SECONDS))))) 114 | 115 | 116 | #?(:bb nil 117 | :clj 118 | (defn stop 119 | "Stop the Vault client, canceling any existing maintenance task. Returns a 120 | stopped version of the component." 121 | [client] 122 | (when-let [task (:maintenance-task client)] 123 | (log/debug "Canceling vault maintenance task") 124 | (future-cancel task)) 125 | (when-let [maintenance-executor (:maintenance-executor client)] 126 | (log/debug "Shutting down vault maintenance executor") 127 | (.shutdownNow ^ExecutorService maintenance-executor)) 128 | (when-let [callback-executor (:callback-executor client)] 129 | (log/debug "Shutting down Vault callback executor") 130 | (.shutdownNow ^ExecutorService callback-executor)) 131 | (dissoc client :maintenance-task :maintenance-executor :callback-executor))) 132 | 133 | 134 | ;; ## Client Construction 135 | 136 | (defn new-client 137 | "Constructs a new Vault client from a URI address by dispatching on the 138 | scheme. The client will be returned in an initialized but not started state. 139 | 140 | Options: 141 | 142 | - `:flow` ([[vault.client.flow/Handler]]) 143 | 144 | Custom control flow handler to use with the client. Defaults to 145 | [[vault.client.flow/sync-handler]]. 146 | 147 | - `:maintenance-period` (integer) 148 | 149 | How frequently (in seconds) to check the client auth token and leased 150 | secrets for renewal or rotation. Defaults to `10` seconds. 151 | 152 | - `:maintenance-executor` (`ScheduledExecutorService`) 153 | 154 | Custom executor to use for maintenance tasks. Defaults to a new 155 | single-threaded executor. 156 | 157 | - `:callback-executor` (`ExecutorService`) 158 | 159 | Custom executor to use for lease callbacks. Defaults to the Clojure agent 160 | send-off pool, same as `future` calls. 161 | 162 | - `:http-opts` (map) 163 | 164 | Additional options to pass to all HTTP requests." 165 | [address & {:as opts}] 166 | (let [uri (URI/create address)] 167 | (case (.getScheme uri) 168 | "mock" 169 | (mock/mock-client address opts) 170 | 171 | ("http" "https") 172 | (http/http-client address opts) 173 | 174 | ;; unknown scheme 175 | (throw (IllegalArgumentException. 176 | (str "Unsupported Vault address scheme: " (pr-str address))))))) 177 | 178 | 179 | (defn config-client 180 | "Configure a client from the environment if possible. Returns the initialized 181 | client, or throws an exception. 182 | 183 | This looks for the following configuration, preferring JVM system properties 184 | over environment variables: 185 | 186 | - `VAULT_ADDR` / `vault.addr` 187 | 188 | The URL of the vault server to connect to. 189 | 190 | - `VAULT_TOKEN` / `vault.token` 191 | 192 | A token to use directly for the client authentication. If neither are set, 193 | this will also try the user's `~/.vault-token` file. 194 | 195 | Accepts the same options as [[new-client]]." 196 | [& {:as opts}] 197 | (let [address (or (System/getProperty "vault.addr") 198 | (System/getenv "VAULT_ADDR") 199 | "mock:-") 200 | token (or (System/getProperty "vault.token") 201 | (System/getenv "VAULT_TOKEN") 202 | (let [token-file (io/file (System/getProperty "user.home") ".vault-token")] 203 | (when (.exists token-file) 204 | (str/trim (slurp token-file))))) 205 | client (new-client address opts)] 206 | (when-not (str/blank? token) 207 | (proto/authenticate! client token)) 208 | client)) 209 | -------------------------------------------------------------------------------- /src/vault/auth/token.clj: -------------------------------------------------------------------------------- 1 | (ns vault.auth.token 2 | "The `/auth/token` endpoint manages token-based authentication functionality. 3 | 4 | Reference: https://www.vaultproject.io/api-docs/auth/token" 5 | (:require 6 | [vault.auth :as auth] 7 | [vault.client.flow :as f] 8 | [vault.client.http :as http] 9 | [vault.client.mock :as mock] 10 | [vault.client.proto :as proto] 11 | [vault.util :as u]) 12 | (:import 13 | vault.client.http.HTTPClient 14 | vault.client.mock.MockClient)) 15 | 16 | 17 | ;; ## API Protocol 18 | 19 | (defprotocol API 20 | "The token auth endpoints manage token authentication functionality." 21 | 22 | (create-token! 23 | [client params] 24 | "Create a new auth token. The token will be a child of the current one 25 | unless the `:no-parent` option is true. This method uses the 26 | `/auth/token/create` endpoint. 27 | 28 | For parameter options, see: 29 | https://www.vaultproject.io/api-docs/auth/token#create-token") 30 | 31 | (create-orphan-token! 32 | [client params] 33 | "Create a new auth token with no parent. This method uses the 34 | `/auth/token/create-orphan` endpoint. 35 | 36 | Parameters are the same as for the `create-token!` call.") 37 | 38 | (create-role-token! 39 | [client role-name params] 40 | "Create a new auth token in the named role. This method uses the 41 | `/auth/token/create/:role-name` endpoint. 42 | 43 | Parameters are the same as for the `create-token!` call.") 44 | 45 | (lookup-token 46 | [client params] 47 | "Look up auth information about a token. 48 | 49 | Depending on the parameters, this method operates on: 50 | - a directly-provided `:token` 51 | - a token `:accessor` 52 | - otherwise, the currently-authenticated token") 53 | 54 | (renew-token! 55 | [client params] 56 | "Renew a lease associated with a token. Token renewal is possible only if 57 | there is a lease associated with it. 58 | 59 | Depending on the parameters, this method operates on: 60 | - a directly-provided `:token` 61 | - a token `:accessor` 62 | - otherwise, the currently-authenticated token 63 | 64 | The parameters may also include a requested `:increment` value.") 65 | 66 | (revoke-token! 67 | [client params] 68 | "Revoke a token and all child tokens. When the token is revoked, all 69 | dynamic secrets generated with it are also revoked. Returns nil. 70 | 71 | Depending on the parameters, this method operates on: 72 | - a directly-provided `:token` 73 | - a token `:accessor` 74 | - otherwise, the currently-authenticated token")) 75 | 76 | 77 | (defn resolve-auth! 78 | "Look up the currently-authenticated token, merging updated information into 79 | the client's auth info. Returns the updated auth data." 80 | [client] 81 | (let [auth-info (f/call-sync lookup-token client {})] 82 | (proto/authenticate! client (assoc auth-info ::auth/token (:id auth-info))) 83 | (proto/auth-info client))) 84 | 85 | 86 | ;; ## Mock Client 87 | 88 | (extend-type MockClient 89 | 90 | API 91 | 92 | (lookup-token 93 | [client params] 94 | (let [root-token "r00t" 95 | root-accessor "TbmQ9IWujYqaUCuQQ2vm3uUY"] 96 | (if (or (= root-token (:token params)) 97 | (= root-accessor (:accessor params)) 98 | (and (nil? (:token params)) 99 | (nil? (:accessor params)))) 100 | (mock/success-response 101 | client 102 | {:id (if (:accessor params) 103 | "" 104 | root-token) 105 | :accessor root-accessor 106 | :creation-time 1630768626 107 | :creation-ttl 0 108 | :display-name "token" 109 | :entity-id "" 110 | :expire-time nil 111 | :explicit-max-ttl 0 112 | :issue-time "2021-09-04T08:17:06-07:00" 113 | :meta nil 114 | :num-uses 0 115 | :orphan true 116 | :path "auth/token/create" 117 | :policies ["root"] 118 | :renewable false 119 | :ttl 0 120 | :type "service"}) 121 | (mock/error-response 122 | client 123 | (ex-info "Vault API errors: bad token" 124 | {:vault.client/errors ["bad token"] 125 | :vault.client/status 403})))))) 126 | 127 | 128 | ;; ## HTTP Client 129 | 130 | (defn- create-token* 131 | "Internal implementation of common token creation logic." 132 | [client label path params] 133 | (let [wrap-ttl (:wrap-ttl params)] 134 | (http/call-api 135 | client label 136 | :post path 137 | {:content-type :json 138 | :headers (when wrap-ttl 139 | {"X-Vault-Wrap-TTL" wrap-ttl}) 140 | :handle-response (if wrap-ttl 141 | (fn handle-wrapped-response 142 | [body] 143 | (u/kebabify-keys (get body "wrap_info"))) 144 | u/kebabify-body-auth) 145 | :body (u/snakify-keys (dissoc params :wrap-ttl))}))) 146 | 147 | 148 | (extend-type HTTPClient 149 | 150 | API 151 | 152 | (create-token! 153 | [client params] 154 | (create-token* 155 | client ::create-token! 156 | "auth/token/create" 157 | params)) 158 | 159 | 160 | (create-orphan-token! 161 | [client params] 162 | (create-token* 163 | client ::create-orphan-token! 164 | "auth/token/create-orphan" 165 | params)) 166 | 167 | 168 | (create-role-token! 169 | [client role-name params] 170 | (create-token* 171 | client ::create-role-token! 172 | (u/join-path "auth/token/create" role-name) 173 | params)) 174 | 175 | 176 | (lookup-token 177 | [client params] 178 | (cond 179 | (:token params) 180 | (http/call-api 181 | client ::lookup-token 182 | :post "auth/token/lookup" 183 | {:content-type :json 184 | :body {:token (:token params)} 185 | :handle-response u/kebabify-body-data}) 186 | 187 | (:accessor params) 188 | (http/call-api 189 | client ::lookup-token 190 | :post "auth/token/lookup-accessor" 191 | {:content-type :json 192 | :body {:accessor (:accessor params)} 193 | :handle-response u/kebabify-body-data}) 194 | 195 | :else 196 | (http/call-api 197 | client ::lookup-token 198 | :get "auth/token/lookup-self" 199 | {:handle-response u/kebabify-body-data}))) 200 | 201 | 202 | (renew-token! 203 | [client params] 204 | (cond 205 | (:token params) 206 | (http/call-api 207 | client ::renew-token! 208 | :post "auth/token/renew" 209 | {:content-type :json 210 | :body (select-keys params [:token :increment]) 211 | :handle-response u/kebabify-body-auth}) 212 | 213 | (:accessor params) 214 | (http/call-api 215 | client ::renew-token! 216 | :post "auth/token/renew-accessor" 217 | {:content-type :json 218 | :body (select-keys params [:accessor :increment]) 219 | :handle-response u/kebabify-body-auth}) 220 | 221 | :else 222 | (http/call-api 223 | client ::renew-token! 224 | :post "auth/token/renew-self" 225 | {:content-type :json 226 | :body (select-keys params [:increment]) 227 | :handle-response u/kebabify-body-auth 228 | :on-success (fn update-auth 229 | [auth] 230 | (proto/authenticate! client auth))}))) 231 | 232 | 233 | (revoke-token! 234 | [client params] 235 | (cond 236 | (:token params) 237 | (http/call-api 238 | client ::revoke-token! 239 | :post "auth/token/revoke" 240 | {:content-type :json 241 | :body {:token (:token params)}}) 242 | 243 | (:accessor params) 244 | (http/call-api 245 | client ::revoke-token! 246 | :post "auth/token/revoke-accessor" 247 | {:content-type :json 248 | :body {:accessor (:accessor params)}}) 249 | 250 | :else 251 | (http/call-api 252 | client ::revoke-token! 253 | :post "auth/token/revoke-self" 254 | {})))) 255 | -------------------------------------------------------------------------------- /test/vault/client/http_test.clj: -------------------------------------------------------------------------------- 1 | (ns vault.client.http-test 2 | (:require 3 | [clojure.test :refer [deftest testing is]] 4 | [org.httpkit.client :as http-client] 5 | [vault.auth :as auth] 6 | [vault.client.flow :as f] 7 | [vault.client.http :as http] 8 | [vault.client.proto :as proto])) 9 | 10 | 11 | (defn mock-request 12 | "Return a function which will simulate an http request callback, with the 13 | extra response values added to the parameters." 14 | [response] 15 | (fn request 16 | [req callback] 17 | (callback (assoc response :opts req)))) 18 | 19 | 20 | (deftest call-api 21 | (let [client {:address "https://vault.test:8200" 22 | :flow f/sync-handler 23 | :auth (atom {::auth/token "t0p-53cr5t"})}] 24 | (testing "with bad arguments" 25 | (with-redefs [http-client/request (fn [_ _] 26 | (is false "should not be called"))] 27 | (is (thrown-with-msg? IllegalArgumentException #"call on nil client" 28 | (http/call-api nil :health :get "sys/health" {}))) 29 | (is (thrown-with-msg? IllegalArgumentException #"call without keyword label" 30 | (http/call-api client nil :get "sys/health" {}))) 31 | (is (thrown-with-msg? IllegalArgumentException #"call without keyword method" 32 | (http/call-api client :health nil "sys/health" {}))) 33 | (is (thrown-with-msg? IllegalArgumentException #"call on blank path" 34 | (http/call-api client :health :get "" {}))))) 35 | (testing "with http call error" 36 | (with-redefs [http-client/request (mock-request 37 | {:error (RuntimeException. "HTTP BOOM")})] 38 | (is (thrown-with-msg? RuntimeException #"HTTP BOOM" 39 | (http/call-api client :foo :get "foo/bar" {}))))) 40 | (testing "with unhandled error" 41 | (with-redefs [http-client/request (mock-request 42 | {:status 200 43 | :body "{uh oh]"})] 44 | (is (thrown-with-msg? Exception #"JSON error" 45 | (http/call-api client :foo :get "foo/bar" {}))))) 46 | (testing "with error response" 47 | (testing "and default handling" 48 | (with-redefs [http-client/request (mock-request 49 | {:status 400 50 | :body "{\"errors\": []}"})] 51 | (is (thrown-with-msg? Exception #"Vault HTTP error on foo/bar \(400\) bad request" 52 | (http/call-api client :foo :get "foo/bar" {}))))) 53 | (testing "and custom handling" 54 | (with-redefs [http-client/request (mock-request 55 | {:status 400 56 | :body "{\"errors\": []}"})] 57 | (is (= :ok (http/call-api 58 | client :foo :get "foo/bar" 59 | {:handle-error (constantly :ok)})))))) 60 | (testing "with redirect" 61 | (testing "and no location header" 62 | (let [calls (atom 0)] 63 | (with-redefs [http-client/request (fn [req callback] 64 | (if (= 1 (swap! calls inc)) 65 | (callback {:opts req 66 | :status 303 67 | :headers {} 68 | ::http/redirects (::http/redirects req)}) 69 | (throw (IllegalStateException. 70 | "should not reach here"))))] 71 | (is (thrown-with-msg? Exception #"redirect without Location header" 72 | (http/call-api client :foo :get "foo/bar" {})))))) 73 | (testing "too many times" 74 | (let [calls (atom 0)] 75 | (with-redefs [http-client/request (fn [req callback] 76 | (when (= 1 @calls) 77 | (is (= "https://vault.test:8200/foo/baz" (:url req)))) 78 | (if (< (swap! calls inc) 5) 79 | (callback {:opts req 80 | :status 307 81 | :headers {"Location" "https://vault.test:8200/foo/baz"} 82 | ::http/redirects (::http/redirects req)}) 83 | (throw (IllegalStateException. "should not reach here"))))] 84 | (is (thrown-with-msg? Exception #"Aborting Vault API request after 3 redirects" 85 | (http/call-api client :foo :get "foo/bar" {})))))) 86 | (testing "successfully" 87 | (let [calls (atom 0)] 88 | (with-redefs [http-client/request (fn [req callback] 89 | (when (= 1 @calls) 90 | (is (= "https://vault.test:8200/foo/baz" (:url req)))) 91 | (if (< (swap! calls inc) 2) 92 | (callback {:opts req 93 | :status 307 94 | :headers {"Location" "https://vault.test:8200/foo/baz"} 95 | ::http/redirects (::http/redirects req)}) 96 | (callback {:opts req 97 | :status 204 98 | :headers {} 99 | ::http/redirects (::http/redirects req)})))] 100 | (is (nil? (http/call-api client :foo :get "foo/bar" {}))))))) 101 | (testing "with successful response" 102 | (testing "with default handling" 103 | (with-redefs [http-client/request (mock-request 104 | {:status 200 105 | :body ""})] 106 | (is (nil? (http/call-api client :foo :get "foo/bar" {}))))) 107 | (testing "with custom handling" 108 | (with-redefs [http-client/request (mock-request 109 | {:status 200 110 | :body "{}"})] 111 | (is (= {:result true} 112 | (http/call-api 113 | client :foo :get "foo/bar" 114 | {:handle-response (constantly {:result true})})))))))) 115 | 116 | 117 | (deftest authentication 118 | (let [client (http/http-client "https://vault.test:8200")] 119 | (testing "with bad input" 120 | (is (thrown-with-msg? IllegalArgumentException #"Client authentication must be a map" 121 | (proto/authenticate! client []))) 122 | (is (thrown-with-msg? IllegalArgumentException #"containing an auth token" 123 | (proto/authenticate! client {})))) 124 | (testing "with token string" 125 | (is (identical? client (proto/authenticate! client "t0p-53cr3t"))) 126 | (is (= {::auth/token "t0p-53cr3t"} (proto/auth-info client)))) 127 | (testing "with auth info" 128 | (is (identical? client (proto/authenticate! 129 | client 130 | {:client-token "t0p-53cr3t" 131 | :ttl 12345}))) 132 | (is (= {::auth/token "t0p-53cr3t"} 133 | (proto/auth-info client)))))) 134 | 135 | 136 | (deftest client-constructor 137 | (testing "with bad address" 138 | (is (thrown-with-msg? IllegalArgumentException #"Vault API address must be a URL with scheme 'http' or 'https'" 139 | (http/http-client :foo))) 140 | (is (thrown-with-msg? IllegalArgumentException #"Vault API address must be a URL with scheme 'http' or 'https'" 141 | (http/http-client "tcp:1234")))) 142 | (testing "with http addresses" 143 | (is (= "http://localhost:8200" (:address (http/http-client "http://localhost:8200")))) 144 | (is (= "https://vault.test:8200" (:address (http/http-client "https://vault.test:8200")))))) 145 | -------------------------------------------------------------------------------- /test/vault/secret/kv/v1_test.clj: -------------------------------------------------------------------------------- 1 | (ns vault.secret.kv.v1-test 2 | (:require 3 | [clojure.test :refer [is testing deftest]] 4 | [vault.client.mock :refer [mock-client]] 5 | [vault.integration :refer [with-dev-server cli]] 6 | [vault.lease :as lease] 7 | [vault.secret.kv.v1 :as kv1] 8 | [vault.util :as u])) 9 | 10 | 11 | (deftest mock-api 12 | (let [client (mock-client)] 13 | (testing "write-secret!" 14 | (testing "with default mount" 15 | (is (nil? (::kv1/mount client))) 16 | (is (nil? (kv1/write-secret! client "test/foo/alpha" {:one :two, :three 456, :seven true}))) 17 | (is (nil? (kv1/write-secret! client "test/foo/beta" {:xyz #{"abc"}}))) 18 | (is (nil? (kv1/write-secret! client "test/gamma" {:map {:a 1, :b 2}})))) 19 | (testing "with alternate mount" 20 | (let [client' (kv1/with-mount client "kv")] 21 | (is (= "kv" (::kv1/mount client'))) 22 | (is (nil? (::kv1/mount (kv1/with-mount client' nil)))) 23 | (is (nil? (kv1/write-secret! client' "alt/test" {:some "thing"})))))) 24 | (testing "list-secrets" 25 | (testing "with default mount" 26 | (is (nil? (kv1/list-secrets client "foo/")) 27 | "should return nil on nonexistent prefix") 28 | (is (nil? (kv1/list-secrets client "test/foo/alpha")) 29 | "should return nil on secret path") 30 | (is (= {:keys ["test/"]} (kv1/list-secrets client "/"))) 31 | (is (= {:keys ["foo/" "gamma"]} (kv1/list-secrets client "test"))) 32 | (is (= {:keys ["alpha" "beta"]} (kv1/list-secrets client "/test/foo/")))) 33 | (testing "with alternate mount" 34 | (let [client' (kv1/with-mount client "kv")] 35 | (is (= {:keys ["test"]} (kv1/list-secrets client' "alt")))))) 36 | (testing "read-secret" 37 | (testing "with default mount" 38 | (is (= {:one "two", :three 456, :seven true} 39 | (kv1/read-secret client "test/foo/alpha"))) 40 | (is (= {:xyz ["abc"]} 41 | (kv1/read-secret client "test/foo/beta"))) 42 | (is (= {:map {:a 1, :b 2}} 43 | (kv1/read-secret client "test/gamma"))) 44 | (testing "on nonexistent path" 45 | (is (thrown-with-msg? Exception #"No kv-v1 secret found at secret:foo/bar" 46 | (kv1/read-secret client "foo/bar"))) 47 | (is (thrown-with-msg? Exception #"No kv-v1 secret found at secret:alt/test" 48 | (kv1/read-secret client "alt/test"))) 49 | (is (= :gone (kv1/read-secret client "alt/test" {:not-found :gone}))))) 50 | (testing "with alternate mount" 51 | (let [client' (kv1/with-mount client "kv")] 52 | (is (= {:some "thing"} 53 | (kv1/read-secret client' "alt/test"))) 54 | (is (thrown-with-msg? Exception #"No kv-v1 secret found at kv:foo/bar" 55 | (kv1/read-secret client' "foo/bar"))) 56 | (is (= :shrug (kv1/read-secret client' "test/foo/alpha" {:not-found :shrug})))))) 57 | (testing "write-secret! update" 58 | (is (nil? (kv1/write-secret! client "test/foo/beta" {:qrs false}))) 59 | (is (= {:qrs false} (kv1/read-secret client "test/foo/beta")) 60 | "should overwrite previous secret")) 61 | (testing "delete-secret!" 62 | (is (nil? (kv1/delete-secret! client "test/gamma"))) 63 | (is (= {:keys ["foo/"]} (kv1/list-secrets client "test"))) 64 | (is (= :deleted (kv1/read-secret client "test/gamma" {:not-found :deleted})))))) 65 | 66 | 67 | (deftest ^:integration http-api 68 | (with-dev-server 69 | (cli "secrets" "disable" "secret/") 70 | (cli "secrets" "enable" "-path=secret" "-version=1" "kv") 71 | (cli "secrets" "enable" "-path=kv" "-version=1" "kv") 72 | (testing "write-secret!" 73 | (testing "with default mount" 74 | (is (nil? (::kv1/mount client))) 75 | (is (nil? (kv1/write-secret! client "test/foo/alpha" {:one :two, :three 456, :seven true}))) 76 | (is (nil? (kv1/write-secret! client "test/foo/beta" {:xyz #{"abc"}}))) 77 | (is (nil? (kv1/write-secret! client "test/gamma" {:map {:a 1, :b 2}})))) 78 | (testing "with alternate mount" 79 | (let [client' (kv1/with-mount client "kv")] 80 | (is (= "kv" (::kv1/mount client'))) 81 | (is (nil? (::kv1/mount (kv1/with-mount client' nil)))) 82 | (is (nil? (kv1/write-secret! client' "alt/test" {:some "thing"})))))) 83 | (testing "list-secrets" 84 | (testing "with default mount" 85 | (is (nil? (kv1/list-secrets client "foo/")) 86 | "should return nil on nonexistent prefix") 87 | (is (nil? (kv1/list-secrets client "test/foo/alpha")) 88 | "should return nil on secret path") 89 | (is (= {:keys ["test/"]} (kv1/list-secrets client "/"))) 90 | (is (= {:keys ["foo/" "gamma"]} (kv1/list-secrets client "test"))) 91 | (is (= {:keys ["alpha" "beta"]} (kv1/list-secrets client "/test/foo/")))) 92 | (testing "with alternate mount" 93 | (let [client' (kv1/with-mount client "kv")] 94 | (is (= {:keys ["test"]} (kv1/list-secrets client' "alt")))))) 95 | (testing "read-secret" 96 | (testing "with default mount" 97 | (is (= {:one "two", :three 456, :seven true} 98 | (kv1/read-secret client "test/foo/alpha"))) 99 | (is (= {:xyz ["abc"]} 100 | (kv1/read-secret client "test/foo/beta"))) 101 | (is (= {:map {:a 1, :b 2}} 102 | (kv1/read-secret client "test/gamma"))) 103 | (testing "on nonexistent path" 104 | (is (thrown-with-msg? Exception #"No kv-v1 secret found at secret:foo/bar" 105 | (kv1/read-secret client "foo/bar"))) 106 | (is (thrown-with-msg? Exception #"No kv-v1 secret found at secret:alt/test" 107 | (kv1/read-secret client "alt/test"))) 108 | (is (= :gone (kv1/read-secret client "alt/test" {:not-found :gone}))))) 109 | (testing "with alternate mount" 110 | (let [client' (kv1/with-mount client "kv")] 111 | (is (= {:some "thing"} 112 | (kv1/read-secret client' "alt/test"))) 113 | (is (thrown-with-msg? Exception #"No kv-v1 secret found at kv:foo/bar" 114 | (kv1/read-secret client' "foo/bar"))) 115 | (is (= :shrug (kv1/read-secret client' "test/foo/alpha" {:not-found :shrug}))))) 116 | (testing "lease caching" 117 | (let [cache-key [::kv1/secret "secret" "test/foo/beta"]] 118 | (lease/invalidate! client cache-key) 119 | (testing "with zero ttl" 120 | (let [result (kv1/read-secret client "test/foo/beta" {:ttl 0})] 121 | (is (= {:xyz ["abc"]} result)) 122 | (is (not (:vault.client/cached? (meta result))) 123 | "should read a new result") 124 | (is (nil? (lease/find-data client cache-key)) 125 | "should not cache data"))) 126 | (testing "with positive ttl" 127 | (is (= {:xyz ["abc"]} 128 | (kv1/read-secret client "test/foo/beta" {:ttl 300}))) 129 | (is (= {:xyz ["abc"]} (lease/find-data client cache-key)) 130 | "should cache data") 131 | (let [result (kv1/read-secret client "test/foo/beta" {:ttl 300})] 132 | (is (:vault.client/cached? (meta result)) 133 | "should read cached result"))) 134 | (testing "with refresh option" 135 | (let [result (kv1/read-secret client "test/foo/beta" {:refresh? true})] 136 | (is (not (:vault.client/cached? (meta result))) 137 | "should read a new result") 138 | (is (= 1 (count (filter #(= cache-key (::lease/key (val %))) 139 | @(u/unveil (:leases client))))) 140 | "should replace old lease")))))) 141 | (testing "write-secret! update" 142 | (is (nil? (kv1/write-secret! client "test/foo/beta" {:qrs false}))) 143 | (is (= {:qrs false} (kv1/read-secret client "test/foo/beta")) 144 | "should overwrite previous secret")) 145 | (testing "delete-secret!" 146 | (is (nil? (kv1/delete-secret! client "test/gamma"))) 147 | (is (= {:keys ["foo/"]} (kv1/list-secrets client "test"))) 148 | (is (= :deleted (kv1/read-secret client "test/gamma" {:not-found :deleted})))) 149 | (testing "invalid mounts" 150 | (is (thrown-with-msg? Exception #"no handler" 151 | (kv1/list-secrets (kv1/with-mount client "wat") "foo/bar"))) 152 | (is (thrown-with-msg? Exception #"no handler" 153 | (kv1/read-secret (kv1/with-mount client "wat") "foo/bar/baz")))))) 154 | -------------------------------------------------------------------------------- /src/vault/auth/approle.clj: -------------------------------------------------------------------------------- 1 | (ns vault.auth.approle 2 | "The `/auth/approle` endpoint manages approle role-id & secret-id authentication functionality. 3 | 4 | Reference: https://www.vaultproject.io/api-docs/auth/approle" 5 | (:require 6 | [vault.client.http :as http] 7 | [vault.client.proto :as proto] 8 | [vault.util :as u]) 9 | (:import 10 | vault.client.http.HTTPClient)) 11 | 12 | 13 | (def default-mount 14 | "Default mount point to use if one is not provided." 15 | "approle") 16 | 17 | 18 | (defprotocol API 19 | "The approle auth endpoints manage role_id and secret_id authentication." 20 | 21 | (with-mount 22 | [client mount] 23 | "Return an updated client which will resolve calls against the provided 24 | mount instead of the default. Passing `nil` will reset the client to the 25 | default.") 26 | 27 | (configure-role! 28 | [client role-name opts] 29 | "Create a new role or update an existing role. At least one option must be 30 | specified. This method uses the `/auth/approle/role/:role_name` endpoint. 31 | 32 | Options: 33 | 34 | - `:bind-secret-id` (boolean) 35 | 36 | If a `secret-id` is required to be presented when logging in with this 37 | role. 38 | 39 | - `:secret-id-bound-cidrs` (collection) 40 | 41 | Collection of CIDR blocks. When set, specifies blocks of IP addresses 42 | which can perform the login operation. 43 | 44 | - `:secret-id-num-uses` (integer) 45 | 46 | The number of times any single `secret-id` can be used to fetch a token 47 | from this approle, after which the `secret-id` will expire. Specify `0` for 48 | unlimited uses. 49 | 50 | - `:secret-id-ttl` (string) 51 | 52 | Duration in either an integer number of seconds (`3600`) or a string time 53 | unit (`60m`) after which any `secret-id` expires. 54 | 55 | - `:local-secret-ids` (boolean) 56 | 57 | If set, the secret IDs generated using this role will be cluster local. 58 | This can only be set during role creation and once set, it can't be reset 59 | later. 60 | 61 | - `:token-ttl` (integer or string) 62 | 63 | The incremental lifetime for generated tokens. 64 | 65 | - `:token-max-ttl` (integer or string) 66 | 67 | The maximum lifetime for generated tokens. 68 | 69 | - `:token-policies` (collection) 70 | 71 | List of policies to encode onto generated tokens. 72 | 73 | - `:token-bound-cidrs` (collection) 74 | 75 | List of CIDR blocks; if set, specifies blocks of IP addresses which can 76 | authenticate successfully. 77 | 78 | - `:token-explicit-max-ttl` (integer or string) 79 | 80 | If set, will encode an explicit hard cap for token life. 81 | 82 | - `:token-no-default-policy` (boolean) 83 | 84 | If set, the default policy will not be set on generated tokens, otherwise 85 | it will be added to the policies set in `:token-policies`. 86 | 87 | - `:token-num-uses` (integer) 88 | 89 | The maximum amount of times a generated token may be used. Specify `0` 90 | for unlimited uses. 91 | 92 | - `:token-period` (integer or string) 93 | 94 | The period to set on a token. 95 | 96 | - `:token-type` (string) 97 | 98 | The type of token that should be generated.") 99 | 100 | (list-roles 101 | [client] 102 | "Return a list of the existing roles. This method uses the 103 | `/auth/approle/role` endpoint.") 104 | 105 | (read-role 106 | [client role-name] 107 | "Read the properities associated with an approle. This method uses the 108 | `/auth/approle/role/:role_name` endpoint.") 109 | 110 | (read-role-id 111 | [client role-name] 112 | "Read the `role-id` of an exiting role. This method uses the 113 | `/auth/approle/role/:role_name/role-id` endpont.") 114 | 115 | (generate-secret-id! 116 | [client role-name] 117 | [client role-name opts] 118 | "Generate a new `secret-id` for an existing role. This method uses the 119 | `/auth/approle/role/:role_name/secret-id` endpoint. 120 | 121 | Options: 122 | 123 | - `:metadata` (string) 124 | 125 | Metadata tied to the `secret-id`. This should be a JSON-formatted string 126 | containing key-value pairs. This metadata is logged in audit logs in plaintext. 127 | 128 | - `:cidr-list` (collection) 129 | 130 | Collection of CIDR blocks enforcing `secret-ids` to be used from specific IP addresses. 131 | 132 | - `:token-bound-cidrs` (collection) 133 | 134 | Collection of CIDR blocks; when set, specifies blocks of IP addresses that can use 135 | auth tokens generated by the `secret-id`.") 136 | 137 | (login 138 | [client role-id secret-id] 139 | "Login using an approle `role-id` and `secret-id`. This method uses the 140 | `/auth/approle/login` endpoint. 141 | 142 | Returns the `auth` map from the login endpoint and updates the auth 143 | information in the client, including the new client token.")) 144 | 145 | 146 | (extend-type HTTPClient 147 | 148 | API 149 | 150 | (with-mount 151 | [client mount] 152 | (if (some? mount) 153 | (assoc client ::mount mount) 154 | (dissoc client ::mount))) 155 | 156 | 157 | (configure-role! 158 | [client role-name opts] 159 | (let [mount (::mount client default-mount) 160 | api-path (u/join-path "auth" mount "role" role-name)] 161 | (http/call-api 162 | client ::configure-role! 163 | :post api-path 164 | {:info {::mount mount, ::role role-name} 165 | :content-type :json 166 | :body (-> opts 167 | (select-keys [:bind-secret-id 168 | :secret-id-bound-cidrs 169 | :secret-id-num-uses 170 | :secret-id-ttl 171 | :local-secret-ids 172 | :token-ttl 173 | :token-max-ttl 174 | :token-policies 175 | :token-bound-cidrs 176 | :token-explicit-max-ttl 177 | :token-no-default-policy 178 | :token-num-uses 179 | :token-period 180 | :token-type]) 181 | (u/snakify-keys))}))) 182 | 183 | 184 | (list-roles 185 | [client] 186 | (let [mount (::mount client default-mount) 187 | api-path (u/join-path "auth" mount "role")] 188 | (http/call-api 189 | client ::list-roles 190 | :list api-path 191 | {:info {::mount mount} 192 | :handle-response u/kebabify-body-data}))) 193 | 194 | 195 | (read-role 196 | [client role-name] 197 | (let [mount (::mount client default-mount) 198 | api-path (u/join-path "auth" mount "role" role-name)] 199 | (http/call-api 200 | client ::read-role 201 | :get api-path 202 | {:info {::mount mount, ::role role-name} 203 | :handle-response u/kebabify-body-data}))) 204 | 205 | 206 | (read-role-id 207 | [client role-name] 208 | (let [mount (::mount client default-mount) 209 | api-path (u/join-path "auth" mount "role" role-name "role-id")] 210 | (http/call-api 211 | client ::read-role-id 212 | :get api-path 213 | {:info {::mount mount, ::role role-name} 214 | :handle-response u/kebabify-body-data}))) 215 | 216 | 217 | (generate-secret-id! 218 | ([client role-name] 219 | (generate-secret-id! client role-name {})) 220 | ([client role-name opts] 221 | (let [mount (::mount client default-mount) 222 | api-path (u/join-path "auth" mount "role" role-name "secret-id")] 223 | (http/call-api 224 | client ::generate-secret-id! 225 | :post api-path 226 | {:info {::mount mount, ::role role-name} 227 | :content-type :json 228 | :body (-> opts 229 | (select-keys [:metadata 230 | :cidr-list 231 | :token-bound-cidrs]) 232 | (u/snakify-keys)) 233 | :handle-response u/kebabify-body-data})))) 234 | 235 | 236 | (login 237 | [client role-id secret-id] 238 | (let [mount (::mount client default-mount) 239 | api-path (u/join-path "auth" mount "login")] 240 | (http/call-api 241 | client ::login 242 | :post api-path 243 | {:info {::mount mount} 244 | :content-type :json 245 | :body {:role_id role-id 246 | :secret_id secret-id} 247 | :handle-response u/kebabify-body-auth 248 | :on-success (fn update-auth 249 | [auth] 250 | (proto/authenticate! client auth))})))) 251 | -------------------------------------------------------------------------------- /src/vault/secret/kv/v1.clj: -------------------------------------------------------------------------------- 1 | (ns vault.secret.kv.v1 2 | "The kv secrets engine is used to store arbitrary secrets within the 3 | configured physical storage for Vault. Writing to a key in the kv-v1 backend 4 | will replace the old value; sub-fields are not merged together. 5 | 6 | Reference: https://www.vaultproject.io/api-docs/secret/kv/kv-v1" 7 | (:require 8 | [clojure.data.json :as json] 9 | [vault.client.http :as http] 10 | [vault.client.mock :as mock] 11 | [vault.lease :as lease] 12 | [vault.util :as u]) 13 | (:import 14 | clojure.lang.IObj 15 | vault.client.http.HTTPClient 16 | vault.client.mock.MockClient)) 17 | 18 | 19 | (def default-mount 20 | "Default mount point to use if one is not provided." 21 | "secret") 22 | 23 | 24 | ;; ## API Protocol 25 | 26 | (defprotocol API 27 | "The kv secrets engine is used to store arbitrary static secrets within 28 | Vault. 29 | 30 | All of the methods in this protocol expect `path` to be relative to the 31 | secret engine mount point. To specify a custom mount, use `with-mount`." 32 | 33 | (with-mount 34 | [client mount] 35 | "Return an updated client which will resolve secrets against the provided 36 | mount instead of the default. Passing `nil` will reset the client to the 37 | default.") 38 | 39 | (list-secrets 40 | [client path] 41 | "List the secret names located under a path prefix location. Returns a map 42 | with a `:keys` vector of name strings, where further folders are suffixed 43 | with `/`. The path must be a folder; calling this method on a file or a 44 | prefix which does not exist will return nil.") 45 | 46 | (read-secret 47 | [client path] 48 | [client path opts] 49 | "Read the secret at the provided path. Returns the secret data, if present. 50 | Throws an exception or returns the provided not-found value if not. 51 | 52 | Options: 53 | 54 | - `:not-found` (any) 55 | 56 | If no secret exists at the given path, return this value instead of 57 | throwing an exception. 58 | 59 | - `:refresh?` (boolean) 60 | 61 | Always make a read for fresh data, even if a cached secret is 62 | available. 63 | 64 | - `:ttl` (integer) 65 | 66 | Cache the data read for the given number of seconds. Overrides the TTL 67 | returned by Vault. A value of zero or less will disable caching. 68 | 69 | Note that Vault internally stores data as JSON, so not all Clojure types 70 | will round-trip successfully!") 71 | 72 | (write-secret! 73 | [client path data] 74 | "Store secret data at the provided path, overwriting any secret that was 75 | previously stored there. Returns nil. Writing a `:ttl` key as part of the 76 | secret will control the pseudo lease duration returned when the secret is 77 | read. 78 | 79 | Note that Vault internally stores data as JSON, so not all Clojure types 80 | will round-trip successfully!") 81 | 82 | (delete-secret! 83 | [client path] 84 | "Delete the secret at the provided path, if any. Returns nil.")) 85 | 86 | 87 | ;; ## Mock Client 88 | 89 | (extend-type MockClient 90 | 91 | API 92 | 93 | (with-mount 94 | [client mount] 95 | (if (some? mount) 96 | (assoc client ::mount mount) 97 | (dissoc client ::mount))) 98 | 99 | 100 | (list-secrets 101 | [client path] 102 | (let [mount (::mount client default-mount) 103 | path (u/trim-path path) 104 | data (get-in @(:memory client) [::data mount]) 105 | result (mock/list-paths (keys data) path)] 106 | (mock/success-response 107 | client 108 | (when (seq result) 109 | {:keys result})))) 110 | 111 | 112 | (read-secret 113 | ([client path] 114 | (read-secret client path nil)) 115 | ([client path opts] 116 | (let [mount (::mount client default-mount) 117 | path (u/trim-path path)] 118 | (if-let [secret (get-in @(:memory client) [::data mount path])] 119 | (mock/success-response 120 | client 121 | (-> secret 122 | (json/read-str) 123 | (u/keywordize-keys))) 124 | (if (contains? opts :not-found) 125 | (mock/success-response client (:not-found opts)) 126 | (mock/error-response 127 | client 128 | (ex-info (str "No kv-v1 secret found at " mount ":" path) 129 | {::mount mount 130 | ::path path}))))))) 131 | 132 | 133 | (write-secret! 134 | [client path data] 135 | (let [mount (::mount client default-mount) 136 | path (u/trim-path path)] 137 | (swap! (:memory client) 138 | assoc-in 139 | [::data mount path] 140 | (json/write-str (u/stringify-keys data))) 141 | (mock/success-response client nil))) 142 | 143 | 144 | (delete-secret! 145 | [client path] 146 | (let [mount (::mount client default-mount) 147 | path (u/trim-path path)] 148 | (swap! (:memory client) update-in [::data mount] dissoc path) 149 | (mock/success-response client nil)))) 150 | 151 | 152 | ;; ## HTTP Client 153 | 154 | (defn- synthesize-lease 155 | "Produce a synthetic map of lease information from the given raw lease, cache 156 | key, and an optional custom TTL. Returns nil if the TTL is present and 157 | non-positive." 158 | [lease cache-key ttl] 159 | (when (or (and (::lease/duration lease) 160 | (nil? ttl)) 161 | (pos? ttl)) 162 | (-> lease 163 | (assoc ::lease/id (str (random-uuid)) 164 | ::lease/key cache-key) 165 | (cond-> 166 | ttl 167 | (assoc ::lease/duration (long ttl) 168 | ::lease/expires-at (.plusSeconds (u/now) (long ttl))))))) 169 | 170 | 171 | (extend-type HTTPClient 172 | 173 | API 174 | 175 | (with-mount 176 | [client mount] 177 | (if (some? mount) 178 | (assoc client ::mount mount) 179 | (dissoc client ::mount))) 180 | 181 | 182 | (list-secrets 183 | [client path] 184 | (let [mount (::mount client default-mount) 185 | path (u/trim-path path)] 186 | (http/call-api 187 | client ::list-secrets 188 | :get (u/join-path mount path) 189 | {:info {::mount mount, ::path path} 190 | :query-params {:list true} 191 | :handle-response 192 | (fn handle-response 193 | [body] 194 | (u/kebabify-keys (get body "data"))) 195 | :handle-error 196 | (fn handle-error 197 | [ex] 198 | (when-not (http/not-found? ex) 199 | ex))}))) 200 | 201 | 202 | (read-secret 203 | ([client path] 204 | (read-secret client path nil)) 205 | ([client path opts] 206 | (let [mount (::mount client default-mount) 207 | path (u/trim-path path) 208 | info {::mount mount, ::path path} 209 | cache-key [::secret mount path] 210 | cached (when-not (:refresh? opts) 211 | (lease/find-data client cache-key))] 212 | (if cached 213 | (http/cached-response client ::read-secret info cached) 214 | (http/call-api 215 | client ::read-secret 216 | :get (u/join-path mount path) 217 | {:info info 218 | :handle-response 219 | (fn handle-response 220 | [body] 221 | (let [lease (synthesize-lease 222 | (http/lease-info body) 223 | cache-key 224 | (:ttl opts)) 225 | data (u/keywordize-keys (get body "data"))] 226 | (when lease 227 | (lease/invalidate! client cache-key) 228 | (lease/put! client lease data)) 229 | (vary-meta data merge lease))) 230 | :handle-error 231 | (fn handle-error 232 | [ex] 233 | (if (http/not-found? ex) 234 | (if-let [[_ not-found] (find opts :not-found)] 235 | (if (instance? IObj not-found) 236 | (vary-meta not-found merge (ex-data ex)) 237 | not-found) 238 | (ex-info (str "No kv-v1 secret found at " mount ":" path) 239 | (ex-data ex))) 240 | ex))}))))) 241 | 242 | 243 | (write-secret! 244 | [client path data] 245 | (let [mount (::mount client default-mount) 246 | path (u/trim-path path) 247 | cache-key [::secret mount path]] 248 | (lease/invalidate! client cache-key) 249 | (http/call-api 250 | client ::write-secret! 251 | :post (u/join-path mount path) 252 | {:info {::mount mount, ::path path} 253 | :content-type :json 254 | :body (u/stringify-keys data)}))) 255 | 256 | 257 | (delete-secret! 258 | [client path] 259 | (let [mount (::mount client default-mount) 260 | path (u/trim-path path) 261 | cache-key [::secret mount path]] 262 | (lease/invalidate! client cache-key) 263 | (http/call-api 264 | client ::delete-secret! 265 | :delete (u/join-path mount path) 266 | {:info {::mount mount, ::path path}})))) 267 | -------------------------------------------------------------------------------- /src/vault/lease.cljc: -------------------------------------------------------------------------------- 1 | (ns vault.lease 2 | "High-level namespace for tracking and maintaining leases on dynamic secrets 3 | read by a vault client." 4 | (:require 5 | [clojure.tools.logging :as log] 6 | [vault.util :as u]) 7 | #?@(:bb 8 | [] 9 | :clj 10 | [(:import 11 | clojure.lang.Agent 12 | java.util.concurrent.ExecutorService)])) 13 | 14 | 15 | ;; ## Data Specs 16 | 17 | (def ^:private lease-spec 18 | "Specification for lease data maps." 19 | {;; Unique lease identifier. 20 | ::id string? 21 | 22 | ;; A cache lookup key for identifying this lease to future calls. 23 | ::key some? 24 | 25 | ;; How long the lease is valid for, in seconds. 26 | ::duration nat-int? 27 | 28 | ;; Instant in time the lease expires at. 29 | ::expires-at inst? 30 | 31 | ;; Secret data map. 32 | ::data map? 33 | 34 | ;; Can this lease be renewed to extend its validity? 35 | ::renewable? boolean? 36 | 37 | ;; How many seconds to attempt to add to the lease duration when renewing. 38 | ::renew-increment pos-int? 39 | 40 | ;; Try to renew this lease when the current time is within this many seconds of 41 | ;; the `expires-at` deadline. 42 | ::renew-within pos-int? 43 | 44 | ;; Wait at least this many seconds between successful renewals of this lease. 45 | ::renew-backoff nat-int? 46 | 47 | ;; Time after which this lease can be attempted to be renewed. 48 | ::renew-after inst? 49 | 50 | ;; A no-argument function to call to rotate this lease. This should return true 51 | ;; if the rotation succeeded, else false. 52 | ::rotate-fn fn? 53 | 54 | ;; Try to read a new secret when the current time is within this many seconds 55 | ;; of the `expires-at` deadline. 56 | ::rotate-within nat-int? 57 | 58 | ;; Function to call with lease info after a successful renewal. 59 | ;; - :client 60 | ;; - :lease 61 | ;; - :data 62 | ::on-renew fn? 63 | 64 | ;; Function to call with lease info after a successful rotation. 65 | ;; - :client 66 | ;; - :lease 67 | ;; - :data 68 | ::on-rotate fn? 69 | 70 | 71 | ;; Function to call with any exceptions thrown during periodic maintenance. 72 | ;; - :client 73 | ;; - :lease 74 | ;; - :data 75 | ;; - :error 76 | ::on-error fn?}) 77 | 78 | 79 | (defn valid? 80 | "True if the lease information map conforms to the spec." 81 | [lease] 82 | (u/validate lease-spec lease)) 83 | 84 | 85 | ;; ## General Functions 86 | 87 | (defn expires-within? 88 | "True if the lease will expire within `ttl` seconds." 89 | [lease ttl] 90 | (let [expires-at (::expires-at lease)] 91 | (or (nil? expires-at) 92 | (-> (u/now) 93 | (.plusSeconds ttl) 94 | (.isBefore expires-at) 95 | (not))))) 96 | 97 | 98 | (defn expired? 99 | "True if the given lease is expired." 100 | [lease] 101 | (expires-within? lease 0)) 102 | 103 | 104 | (defn renewable-lease 105 | "Helper to apply common renewal settings to the lease map. 106 | 107 | Options may contain: 108 | 109 | - `:renew?` 110 | If true, attempt to automatically renew the lease when near expiry. 111 | (Default: false) 112 | - `:renew-within` 113 | Renew the lease when within this many seconds of the lease expiry. 114 | (Default: 60) 115 | - `:renew-increment` 116 | How long to request the lease be renewed for, in seconds. 117 | - `:on-renew` 118 | A function to call with the updated lease information after a successful 119 | renewal. 120 | - `:on-error` 121 | A function to call with any exceptions encountered while renewing or 122 | rotating the lease." 123 | [lease opts] 124 | (if (and (:renew? opts) (::renewable? lease)) 125 | (-> lease 126 | (assoc ::renew-within (:renew-within opts 60)) 127 | (cond-> 128 | (:renew-increment opts) 129 | (assoc ::renew-increment (:renew-increment opts)) 130 | 131 | (:on-renew opts) 132 | (assoc ::on-renew (:on-renew opts)) 133 | 134 | (:on-error opts) 135 | (assoc ::on-error (:on-error opts)))) 136 | lease)) 137 | 138 | 139 | (defn rotatable-lease 140 | "Helper to apply common rotation settings to the lease map. The rotation 141 | function will be called with no arguments and should synchronously return 142 | a new secret data result, and update the lease store as a side-effect. 143 | 144 | Options may contain: 145 | 146 | - `:rotate?` 147 | If true, attempt to read a new secret when the lease can no longer be 148 | renewed. (Default: false) 149 | - `:rotate-within` 150 | Rotate the secret when within this many seconds of the lease expiry. 151 | (Default: 60) 152 | - `:on-rotate` 153 | A function to call with the new secret data after a successful rotation. 154 | - `:on-error` 155 | A function to call with any exceptions encountered while renewing or 156 | rotating the lease." 157 | [lease opts rotate-fn] 158 | (when-not rotate-fn 159 | (throw (IllegalArgumentException. 160 | "Can't make a lease rotatable with no rotation function"))) 161 | (if (:rotate? opts) 162 | (-> lease 163 | (assoc ::rotate-fn rotate-fn 164 | ::rotate-within (:rotate-within opts 60)) 165 | (cond-> 166 | (:on-rotate opts) 167 | (assoc ::on-rotate (:on-rotate opts)) 168 | 169 | (:on-error opts) 170 | (assoc ::on-error (:on-error opts)))) 171 | lease)) 172 | 173 | 174 | ;; ## Lease Tracking 175 | 176 | (defn- valid-store? 177 | "Checks a store state for validity." 178 | [state] 179 | (every? 180 | (fn valid-entry? 181 | [[id info]] 182 | (and (string? id) (valid? info))) 183 | state)) 184 | 185 | 186 | (defn new-store 187 | "Construct a new stateful store for leased secrets." 188 | [] 189 | (u/veil (atom {} :validator valid-store?))) 190 | 191 | 192 | (defn get-lease 193 | "Retrieve a lease from the store. Returns the lease information, including 194 | secret data, or nil if not found or expired." 195 | [client lease-id] 196 | (when-let [store (u/unveil (:leases client))] 197 | (when-let [lease (get @store lease-id)] 198 | (when-not (expired? lease) 199 | lease)))) 200 | 201 | 202 | (defn find-data 203 | "Retrieve an existing leased secret from the store by cache key. Returns the 204 | secret data, or nil if not found or expired." 205 | [client cache-key] 206 | (when-let [store (u/unveil (:leases client))] 207 | (let [lease (first (filter (comp #{cache-key} ::key) (vals @store))) 208 | data (::data lease)] 209 | (when (and data (not (expired? lease))) 210 | (vary-meta data merge (dissoc lease ::data)))))) 211 | 212 | 213 | (defn put! 214 | "Persist a leased secret in the store. Returns the lease data." 215 | [client lease data] 216 | (when-let [store (u/unveil (:leases client))] 217 | (when-not (expired? lease) 218 | (swap! store assoc (::id lease) (assoc lease ::data data)))) 219 | (vary-meta data merge lease)) 220 | 221 | 222 | (defn update! 223 | "Merge some updated information into an existing lease. Updates should 224 | contain a `::lease/id`. Returns the updated lease, or nil if no such lease 225 | was present." 226 | [client updates] 227 | (when-let [store (u/unveil (:leases client))] 228 | (let [lease-id (::id updates)] 229 | (-> store 230 | (swap! u/update-some lease-id merge updates) 231 | (get lease-id))))) 232 | 233 | 234 | (defn delete! 235 | "Remove an entry for the given lease, if present." 236 | [client lease-id] 237 | (when-let [store (u/unveil (:leases client))] 238 | (swap! store dissoc lease-id)) 239 | nil) 240 | 241 | 242 | (defn invalidate! 243 | "Remove entries matching the given cache key." 244 | [client cache-key] 245 | (when-let [store (u/unveil (:leases client))] 246 | (swap! store (fn remove-keys 247 | [leases] 248 | (into (empty leases) 249 | (remove (comp #{cache-key} ::key val)) 250 | leases)))) 251 | nil) 252 | 253 | 254 | ;; ## Maintenance Logic 255 | 256 | (defn- renew? 257 | "True if the lease should be renewed." 258 | [lease] 259 | (and (::renewable? lease) 260 | (expires-within? lease (::renew-within lease 0)) 261 | (not (expired? lease)) 262 | (if-let [gate (::renew-after lease)] 263 | (.isAfter (u/now) gate) 264 | true))) 265 | 266 | 267 | (defn- rotate? 268 | "True if the lease should be rotated." 269 | [lease] 270 | (and (::rotate-fn lease) 271 | (expires-within? lease (::rotate-within lease 0)))) 272 | 273 | 274 | (defn- invoke-callback 275 | "Invoke a callback function with the lease information." 276 | ([cb-key client lease] 277 | (invoke-callback cb-key client lease nil nil)) 278 | ([cb-key client lease data] 279 | (invoke-callback cb-key client lease data nil)) 280 | ([cb-key client lease data error] 281 | (when-let [callback (get lease cb-key)] 282 | (let [executor #?(:bb nil 283 | :clj (or (:callback-executor client) 284 | Agent/soloExecutor)) 285 | runnable #(callback 286 | {:client client 287 | :lease (dissoc lease ::data) 288 | :data (or data (::data lease)) 289 | :error error})] 290 | #?(:bb (future (runnable)) 291 | :clj (.submit ^ExecutorService executor ^Runnable runnable)))))) 292 | 293 | 294 | (defn- renew! 295 | "Attempt to renew the lease, handling callbacks. Returns true if the renewal 296 | succeeded, false if not. The renewal function will be called with the lease 297 | and should synchronously return updated lease info. The lease store should be 298 | updated as a side-effect." 299 | [client lease renew-fn] 300 | (try 301 | (let [result (renew-fn lease)] 302 | (invoke-callback ::on-renew client result) 303 | true) 304 | (catch Exception ex 305 | (invoke-callback ::on-error client lease nil ex) 306 | false))) 307 | 308 | 309 | (defn- rotate! 310 | "Attempt to rotate a secret, handling callbacks. Returns true if the rotation 311 | succeeded, false if not. The rotation function will be called with no 312 | arguments and should synchronously return a result or throw an error. The 313 | lease store should be updated as a side-effect." 314 | [client lease] 315 | (try 316 | (let [rotate-fn (::rotate-fn lease) 317 | result (rotate-fn)] 318 | (invoke-callback ::on-rotate client lease result) 319 | true) 320 | (catch Exception ex 321 | (invoke-callback ::on-error client lease nil ex) 322 | false))) 323 | 324 | 325 | (defn- maintain-lease! 326 | "Maintain a single secret lease as appropriate. Returns a keyword indicating 327 | the action and final state of the lease." 328 | [client lease renew-fn] 329 | (try 330 | (cond 331 | (renew? lease) 332 | (if (renew! client lease renew-fn) 333 | :renew-ok 334 | :renew-fail) 335 | 336 | (rotate? lease) 337 | (if (rotate! client lease) 338 | :rotate-ok 339 | :rotate-fail) 340 | 341 | (expired? lease) 342 | :expired 343 | 344 | :else 345 | :active) 346 | (catch Exception ex 347 | (log/error ex "Unhandled error while maintaining lease" (::id lease)) 348 | (invoke-callback ::on-error client lease nil ex) 349 | :error))) 350 | 351 | 352 | (defn maintain! 353 | "Maintain all the leases in the store, blocking until complete." 354 | [client renew-fn] 355 | (when-let [store (u/unveil (:leases client))] 356 | (doseq [[lease-id lease] @store] 357 | (case (maintain-lease! client lease renew-fn) 358 | ;; After successful renewal, set a backoff before we try to renew again. 359 | :renew-ok 360 | (let [after (.plusSeconds (u/now) (::renew-backoff lease 60))] 361 | (swap! store assoc-in [lease-id ::renew-after] after)) 362 | 363 | ;; After rotating, remove the old lease. 364 | :rotate-ok 365 | (swap! store dissoc lease-id) 366 | 367 | ;; Remove expired leases. 368 | :expired 369 | (swap! store dissoc lease-id) 370 | 371 | ;; In other cases, there's no action to take. 372 | nil)))) 373 | -------------------------------------------------------------------------------- /src/vault/secret/transit.clj: -------------------------------------------------------------------------------- 1 | (ns vault.secret.transit 2 | "The transit secrets engine handles cryptographic functions on data 3 | in-transit. It can also be viewed as \"cryptography as a service\" or 4 | \"encryption as a service\". The transit secrets engine can also sign and 5 | verify data; generate hashes and HMACs of data; and act as a source of random 6 | bytes. 7 | 8 | Reference: https://www.vaultproject.io/api-docs/secret/transit" 9 | (:require 10 | [vault.client.http :as http] 11 | [vault.util :as u]) 12 | (:import 13 | java.time.Instant 14 | vault.client.http.HTTPClient)) 15 | 16 | 17 | (def default-mount "transit") 18 | 19 | 20 | (defprotocol API 21 | 22 | (with-mount 23 | [client mount] 24 | "Return an updated client which will resolve secrets against the provided 25 | mount instead of the default. Passing `nil` will reset the client to the 26 | default.") 27 | 28 | (read-key 29 | [client key-name] 30 | "Look up information about a named encryption key. The keys object shows 31 | the creation time of each key version; the values are not the keys 32 | themselves.") 33 | 34 | (rotate-key! 35 | [client key-name] 36 | [client key-name opts] 37 | "Rotate the version of the named key. Returns the key information map. 38 | 39 | After rotation, new encryption requests will use the new version of the 40 | key. To upgrade existing ciphertext to be encrypted with the latest version 41 | of the key, use `rewrap`. 42 | 43 | Options: 44 | 45 | - `:managed-key-name` (string) 46 | 47 | The name of the managed key to use for this key. 48 | 49 | - `:managed-key-id` (string) 50 | 51 | The UUID of the managed key to use for this key. One of 52 | `:managed-key-name` or `:managed-key-id` is required if the key type is 53 | `:managed-key`.") 54 | 55 | (update-key-configuration! 56 | [client key-name opts] 57 | "Update configuration values for a given key. Returns the key information 58 | map. 59 | 60 | Options: 61 | 62 | - `:min-decryption-version` (integer) 63 | 64 | Minimum version of the key allowed to decrypt payloads. 65 | 66 | - `:min-encryption-version` (integer) 67 | 68 | Minimum version of the key allowed to encrypt payloads. Must be `0` 69 | (which specifies the latest version), or greater than `:min-decryption-version`. 70 | 71 | - `:deletion-allowed` (boolean) 72 | 73 | True if the key is allowed to be deleted. 74 | 75 | - `:exportable` (boolean) 76 | 77 | True if the key is be allowed to be exported. Once set, cannot be disabled. 78 | 79 | - `:allow-plaintext-backup` (boolean) 80 | 81 | True if plaintext backups of the key are allowed. Once set, cannot be 82 | disabled. 83 | 84 | - `:auto-rotate-period` (string) 85 | 86 | The period at which this key should be rotated automatically, expressed 87 | as a duration format string. Setting this to \"0\" will disable automatic 88 | key rotation. This value cannot be shorter than one hour. When no value 89 | is provided, the period remains unchanged.") 90 | 91 | (encrypt-data! 92 | [client key-name data] 93 | [client key-name data opts] 94 | "Encrypt data using the named key. Supports create and update. If a user 95 | only has update permissions and the key does not exist, an error will be 96 | returned. 97 | 98 | In single-item mode, `data` may either be a string or a byte array, and 99 | will be automatically base64-encoded. Returns a map with the `:ciphertext` 100 | string and the `:key-version` used to encrypt it. 101 | 102 | For batch operation, `data` should be a sequence of maps, each containing 103 | their own `:plaintext` and optional `:context`, `:nonce`, and `:reference` 104 | entries. Returns a vector of batch results, each with `:ciphertext`, 105 | `:key-version`, and `:reference`. 106 | 107 | Options: 108 | 109 | - `:associated-data` (string or bytes) 110 | 111 | Associated data which won't be encrypted but will be authenticated. 112 | Automatically base64-encoded. 113 | 114 | - `:context` (string or bytes) 115 | 116 | The context for key derivation. Required if key derivation is enabled. 117 | Automatically base64-encoded. 118 | 119 | - `:key-version` (integer) 120 | 121 | The version of the key to use for encryption. Uses latest version if not 122 | set. Must be greater than or equal to the key's `:min-encryption-version`. 123 | 124 | - `:nonce` (bytes) 125 | 126 | The nonce to use for encryption. This must be 96 bits (12 bytes) long and 127 | may not be reused. Automatically base64-encoded. 128 | 129 | - `:reference` (string) 130 | 131 | A string to help identify results when using batch mode. No effect in 132 | single-item mode. 133 | 134 | - `:type` (string) 135 | 136 | The type of key to create. Required if the key does not exist. 137 | 138 | - `:convergent-encryption` (boolean) 139 | 140 | Whether to support convergent encryption on a new key. See the Vault docs 141 | for details. 142 | 143 | - `:partial-failure-response-code` (integer) 144 | 145 | If set, will return this HTTP response code instead of a 400 if some but 146 | not all members of a batch fail to encrypt.") 147 | 148 | (decrypt-data! 149 | [client key-name data] 150 | [client key-name data opts] 151 | "Decrypt data using the named key. 152 | 153 | In single-item mode, `data` should be the ciphertext string returned from 154 | [[encrypt-data!]]. Returns a map with the `:plaintext` data decoded into a 155 | byte array. 156 | 157 | In batch mode, `data` should be a sequence of maps, each containing their 158 | own `:ciphertext` and optional `:context`, `:nonce`, and `:reference` 159 | entries. Returns a vector of batch results, each with `:plaintext` and 160 | `:reference`. 161 | 162 | Options: 163 | 164 | - `:as-string` (boolean) 165 | 166 | Set to true to have the plaintext data decoded into a string instead of a 167 | byte array. 168 | 169 | - `:associated-data` (string) 170 | 171 | Associated data to be authenticated (but not decrypted). 172 | 173 | - `:context` (string or bytes) 174 | 175 | The context for key derivation. Required if key derivation is enabled. 176 | Automatically base64-encoded. 177 | 178 | - `:nonce` (bytes) 179 | 180 | The nonce used for encryption. Automatically base64-encoded. 181 | 182 | - `:reference` (string) 183 | 184 | A string to help identify results when using batch mode. No effect in 185 | single-item mode. 186 | 187 | - `:partial-failure-response-code` (integer) 188 | 189 | If set, will return this HTTP response code instead of a 400 if some but 190 | not all members of a batch fail to decrypt.")) 191 | 192 | 193 | ;; ## HTTP Client 194 | 195 | (defn- parse-key-info 196 | "Parse the key information map returned by [[read-key]] and [[rotate-key!]]." 197 | [body] 198 | (let [data (u/kebabify-body-data body) 199 | versions (into {} 200 | (map (fn parse-version 201 | [[version created-at]] 202 | [(or (parse-long (name version)) version) 203 | (try 204 | (Instant/ofEpochSecond created-at) 205 | (catch Exception _ 206 | created-at))])) 207 | (:keys data))] 208 | (assoc data :keys versions))) 209 | 210 | 211 | (extend-type HTTPClient 212 | 213 | API 214 | 215 | (with-mount 216 | [client mount] 217 | (if (some? mount) 218 | (assoc client ::mount mount) 219 | (dissoc client ::mount))) 220 | 221 | 222 | (read-key 223 | [client key-name] 224 | (let [mount (::mount client default-mount)] 225 | (http/call-api 226 | client ::read-key 227 | :get (u/join-path mount "keys" key-name) 228 | {::info {::mount mount, ::key key-name} 229 | :content-type :json 230 | :handle-response parse-key-info}))) 231 | 232 | 233 | (rotate-key! 234 | ([client key-name] 235 | (rotate-key! client key-name nil)) 236 | ([client key-name opts] 237 | (let [mount (::mount client default-mount)] 238 | (http/call-api 239 | client ::rotate-key! 240 | :post (u/join-path mount "keys" key-name "rotate") 241 | {:info {::mount mount, ::key key-name} 242 | :content-type :json 243 | :body (u/snakify-keys opts) 244 | :handle-response parse-key-info})))) 245 | 246 | 247 | (update-key-configuration! 248 | [client key-name opts] 249 | (let [mount (::mount client default-mount)] 250 | (http/call-api 251 | client ::update-key-configuration! 252 | :post (u/join-path mount "keys" key-name "config") 253 | {:info {::mount mount, ::key key-name} 254 | :content-type :json 255 | :body (u/snakify-keys opts) 256 | :handle-response parse-key-info}))) 257 | 258 | 259 | (encrypt-data! 260 | ([client key-name data] 261 | (encrypt-data! client key-name data nil)) 262 | ([client key-name data opts] 263 | (when-not (or (string? data) (bytes? data) (coll? data)) 264 | (throw (IllegalArgumentException. 265 | (str "Expected data to be a string, bytes, or a batch collection; got: " 266 | (class data))))) 267 | (let [mount (::mount client default-mount) 268 | batch (when (coll? data) 269 | (mapv (fn prepare-batch 270 | [entry] 271 | (-> entry 272 | (select-keys [:plaintext :context :nonce :reference]) 273 | (u/update-some :context u/base64-encode) 274 | (u/update-some :nonce u/base64-encode) 275 | (update :plaintext u/base64-encode))) 276 | data))] 277 | (http/call-api 278 | client ::encrypt-data! 279 | :post (u/join-path mount "encrypt" key-name) 280 | {:info {::mount mount, ::key key-name} 281 | :content-type :json 282 | :body (-> (if batch 283 | (assoc opts :batch-input batch) 284 | (assoc opts :plaintext (u/base64-encode data))) 285 | (u/update-some :associated-data u/base64-encode) 286 | (u/update-some :context u/base64-encode) 287 | (u/update-some :nonce u/base64-encode) 288 | (u/snakify-keys)) 289 | :handle-response (fn coerce-response 290 | [body] 291 | (let [data (u/kebabify-body-data body)] 292 | (or (:batch-results data) 293 | data)))})))) 294 | 295 | 296 | (decrypt-data! 297 | ([client key-name data] 298 | (decrypt-data! client key-name data nil)) 299 | ([client key-name data opts] 300 | (when-not (or (string? data) (coll? data)) 301 | (throw (IllegalArgumentException. 302 | (str "Expected data to be a string or a batch collection; got: " 303 | (class data))))) 304 | (let [mount (::mount client default-mount) 305 | batch (when (coll? data) 306 | (mapv (fn prepare-batch 307 | [entry] 308 | (-> entry 309 | (select-keys [:ciphertext :context :nonce :reference]) 310 | (u/update-some :context u/base64-encode) 311 | (u/update-some :nonce u/base64-encode))) 312 | data)) 313 | decode-plaintext (fn decode-plaintext 314 | [data] 315 | (u/update-some data :plaintext u/base64-decode (:as-string opts)))] 316 | (http/call-api 317 | client ::decrypt-data! 318 | :post (u/join-path mount "decrypt" key-name) 319 | {:info {::mount mount, ::key key-name} 320 | :content-type :json 321 | :body (-> (if (string? data) 322 | (assoc opts :ciphertext data) 323 | (assoc opts :batch-input batch)) 324 | (dissoc :as-string) 325 | (u/update-some :associated-data u/base64-encode) 326 | (u/update-some :context u/base64-encode) 327 | (u/update-some :nonce u/base64-encode) 328 | (u/snakify-keys)) 329 | :handle-response (fn coerce-response 330 | [body] 331 | (let [data (u/kebabify-body-data body) 332 | batch (:batch-results data)] 333 | (if batch 334 | (mapv decode-plaintext batch) 335 | (decode-plaintext data))))}))))) 336 | --------------------------------------------------------------------------------