├── 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 | [](https://dl.circleci.com/status-badge/redirect/gh/amperity/vault-clj/tree/main)
5 | [](https://codecov.io/gh/amperity/vault-clj)
6 | [](https://clojars.org/com.amperity/vault-clj)
7 | [](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 |
--------------------------------------------------------------------------------