defmodule Vault do
@moduledoc """
The main module for configuring and interacting with HashiCorp's Vault.
"""
require Logger
@http if Code.ensure_loaded?(Tesla), do: Vault.HTTP.Tesla, else: nil
@json if Code.ensure_loaded?(Jason),
do: Jason,
else: if(Code.ensure_loaded?(Poison), do: Poison, else: nil)
defstruct http: @http,
json: @json,
host: nil,
auth: nil,
auth_path: nil,
engine: Vault.Engine.Generic,
token: nil,
token_expires_at: nil,
http_options: [],
credentials: %{}
@type options :: map() | Keyword.t()
@type http :: Vault.HTTP.Adapter.t() | nil
@type auth :: Vault.Auth.Adapter.t() | nil
@type auth_path :: String.t()
@type engine :: Vault.Engine.Adapter.t() | nil
@type json :: Vault.Json.Adapter.t() | nil
@type host :: String.t()
@type token_expires_at :: NaiveDateTime.t()
@type token :: String.t() | nil
@type credentials :: map()
@type method :: :get | :put | :post | :patch | :head | :delete
@type t :: %__MODULE__{
http: http,
json: json,
host: host,
auth: auth,
auth_path: auth_path,
engine: engine,
token: token,
token_expires_at: token_expires_at,
credentials: credentials
}
@doc """
Create a new client. Optionally provide a keyword list or map of options for
configuration.
## Examples
Return a default Vault client:
vault = Vault.new()
Return a fully initialized Vault Client:
vault = Vault.new(%{
http: Vault.HTTP.Tesla,
host: "my-vault-instance.example.com",
auth: Vault.Auth.JWT,
auth_path: 'jwt',
engine: Vault.Engine.Generic,
token: "abc123",
token_expires_at: NaiveDateTime.utc_now() |> NaiveDateTime.add(30, :second),
credentials: %{role_id: "dev-role", jwt: "averylongstringoflettersandnumbers..."}
})
### Options
The following options can be provided as part of the `:vault` application
config, or as a Keyword List or Map of options. Runtime configuration will
always take precedence.
* `:auth` - Module for your Auth adapter.
* `:auth_path` - Path to use for your auth adapter. Provided adapters have
their own default paths. Check your adapter for details.
* `:engine` Module for your Secret Engine adapter. Defaults to `Vault.Engine.Generic`.
* `:host` - host of your vault instance. Should contain the port, if needed.
Should not contain a trailing slash. Defaults to
`System.get_env("VAULT_ADDR")`.
* `:http` - Module for your http adapter. Defaults to `Vault.HTTP.Tesla`
when `:tesla` is present.
* `:http_options` - A keyword list of options to your HTTP adapter.
* `:token` - A vault token.
* `:token_expires_at` A `NaiveDateTime` instance that represents when the
token expires, in utc.
* `:credentials` - The credentials to use when authenticating with your
Auth adapter.
"""
@spec new() :: t
@spec new(options) :: t
def new(params \\ %{}) when is_list(params) or is_map(params) do
params = Map.merge(%{host: System.get_env("VAULT_ADDR")}, Map.new(params))
struct(__MODULE__, params)
end
@doc """
Set the host of your vault instance.
## Examples
The host can be fetched from anywhere, as long as it's a string.
vault = Vault.set_host(vault, System.get_env("VAULT_ADDR"))
The port should be provided if needed, along with the protocol.
vault = Vault.set_host(vault, "https://my-vault.host.com:12345")
"""
@spec set_host(t, host) :: t
def set_host(%__MODULE__{} = vault, host) when is_binary(host) do
# TODO - move host formatting niceties to a shared location.
host = if String.starts_with?(host, "http"), do: host, else: "https://" <> host
%{vault | host: String.trim_trailing(host, "/")}
end
@doc """
Set the http module used to make API calls.
## Examples
Should be a module that meets the `Vault.HTTP.Adapter` behaviour.
vault = Vault.set_http(vault, Vault.HTTP.Tesla)
"""
@spec set_http(t, http) :: t
def set_http(%__MODULE__{} = vault, http) do
%{vault | http: http}
end
@doc """
Set the secret engine for the client.
## Examples
The secret engine should be a module that meets the `Vault.Engine.Adapter`
behaviour.
vault = Vault.set_engine(vault, Vault.Engine.KVV2)
"""
@spec set_engine(t, engine) :: t
def set_engine(%__MODULE__{} = vault, engine) do
%{vault | engine: engine}
end
@doc """
Set the backend to use for authenticating the client.
## Examples
The auth backend should be a module that meets the `Vault.Auth.Adapter`
behaviour.
vault = Vault.set_auth(vault, Vault.Auth.Approle)
"""
@spec set_auth(t, auth) :: t
def set_auth(%__MODULE__{} = vault, auth) do
%{vault | auth: auth}
end
@doc """
Set the path used when logging in with your auth adapter.
## Examples
Auth backends can be mounted at any path on `/auth/`. If left unset, the auth adapter may
provide a default, eg `userpass`. See your Auth adapter for details.
vault = Vault.set_auth_path(vault, "auth-path")
"""
@spec set_auth_path(t, auth_path) :: t
def set_auth_path(%__MODULE__{} = vault, auth_path) when is_binary(auth_path) do
path = String.trim_leading(auth_path, "/")
%{vault | auth_path: path}
end
@doc """
Sets the login credentials for this client.
## Examples
vault = Vault.set_credentials(vault, %{username: "UserN4me", password: "P@55w0rd"})
"""
@spec set_credentials(t, map) :: t
def set_credentials(%__MODULE__{} = vault, creds) when is_map(creds) do
%{vault | credentials: creds}
end
@doc """
Authenticate against the configured auth backend.
## Examples
A successful authentication returns a client containing a valid token, as
well as the expiration time for the token. Perform this operation before
reading or writing secrets.
Errors from vault are returned as a list of strings.
Uses pre-configured credentials if provided. Passed in credentials will
override existing credentials.
{:ok, vault} = Vault.set_credentials(vault, %{username: "UserN4me", password: "P@55w0rd"})
{:error, ["Missing Credentials, username and password are required"]} =
Vault.set_credentials(vault, %{username: "whoops"})
"""
@spec auth(t) :: {:ok, t} | {:error, [term]}
@spec auth(t, map) :: {:ok, t} | {:error, [term]}
def auth(vault, params \\ %{})
def auth(%__MODULE__{auth: _, http: nil}, _params), do: {:error, ["http client not set"]}
def auth(%__MODULE__{auth: nil, http: _}, _params), do: {:error, ["auth client not set"]}
def auth(%__MODULE__{auth: auth, credentials: creds} = vault, params) do
new_creds = if is_map(creds), do: Map.merge(creds, params), else: params
case auth.login(vault, new_creds) do
{:ok, token, ttl} ->
expires_at = NaiveDateTime.utc_now() |> NaiveDateTime.add(ttl, :second)
{:ok,
%{
vault
| token: token,
token_expires_at: expires_at,
credentials: new_creds
}}
{:error, _reason} = otherwise ->
otherwise
end
end
@doc """
Check if the current token is still valid.
## Examples
Returns true if the current time is later than the expiration date, otherwise
false.
true = Vault.token_expired?(vault)
"""
@spec token_expired?(t) :: true | false
def token_expired?(%__MODULE__{token_expires_at: nil}), do: true
def token_expired?(%__MODULE__{token_expires_at: expires_at}) do
case NaiveDateTime.compare(expires_at, NaiveDateTime.utc_now()) do
:lt ->
true
_ ->
false
end
end
@doc """
Get a `NaiveDateTime` struct, in UTC, for when the current token will expire.
## Examples
Expiration time is generated from the current time on the current server.
~N[2018-11-25 16:30:30.177731] = Vault.token_expires_at(vault)
"""
def token_expires_at(client), do: client.token_expires_at
@doc """
Read a secret from the configured secret engine.
## Examples
Provided adapters return the values on the `data` key from vault, if present.
See Secret Engine adapter details for additional configuration, such as
returning the full response.
Errors from vault are returned as a list of strings.
{:ok, %{ password: "value" }} = Vault.write(vault,"secret/path/to/read")
{:error, ["Unauthorized"]} = Vault.read(vault,"secret/bad/path")
"""
@spec read(t, String.t()) :: {:ok, map} | {:error, term}
@spec read(t, String.t(), keyword) :: {:ok, map} | {:error, term}
def read(vault, path, options \\ [])
def read(%__MODULE__{engine: _, http: nil}, _path, _options),
do: {:error, ["http client not set"]}
def read(%__MODULE__{engine: nil, http: _}, _path, _options),
do: {:error, ["secret engine not set"]}
def read(%__MODULE__{engine: engine} = vault, path, options) do
engine.read(vault, String.trim_leading(path, "/"), options)
end
@doc """
Write a secret to the configured secret engine.
## Examples
Provided adapters returns the values on the `data` key from vault, if
present. See Secret Engine adapter details for additional configuration,
such as returning the full response.
Errors from vault are returned as a list of strings.
{:ok, %{ version: 1 }} = Vault.write(vault,"secret/path/to/write", %{ secret: "value"})
{:error, ["Unauthorized"]} = Vault.write(vault,"secret/bad/path", %{ secret: "value"})
"""
@spec write(t, String.t(), term) :: {:ok, map} | {:error, term}
@spec write(t, String.t(), term, keyword) :: {:ok, map} | {:error, term}
def write(vault, path, value, options \\ [])
def write(%__MODULE__{engine: _, http: nil}, _path, _value, _options),
do: {:error, ["http client not set"]}
def write(%__MODULE__{engine: nil, http: _}, _path, _value, _options),
do: {:error, ["secret engine not set"]}
def write(%__MODULE__{engine: engine} = vault, path, value, options) do
case engine.write(vault, String.trim_leading(path, "/"), value, options) do
{:ok, data} ->
{:ok, Map.merge(%{"value" => value}, data || %{})}
otherwise ->
otherwise
end
end
@doc """
List secret keys available at a certain path.
## Examples
Path should end with a trailing slash. Provided adapters returns the values
on the `data` key from vault, if present. See Secret Engine adapter details
for additional configuration, such as returning the full response.
Errors from vault are returned as a list of strings.
{:ok, %{ "keys" => ["some/", "paths", "returned"] }} = Vault.list(vault,"secret/path/to/write")
{:error, ["Unauthorized"]} = Vault.list(vault,"secret/bad/path/")
"""
@spec list(t, String.t()) :: {:ok, map} | {:error, term}
@spec list(t, String.t(), keyword) :: {:ok, map} | {:error, term}
def list(vault, path, options \\ [])
def list(%__MODULE__{engine: _, http: nil}, _path, _options),
do: {:error, ["http client not set"]}
def list(%__MODULE__{engine: nil, http: _}, _path, _options),
do: {:error, ["secret engine not set"]}
def list(%__MODULE__{engine: engine} = vault, path, options) do
engine.list(vault, String.trim_leading(path, "/"), options)
end
@doc """
Delete a secret from the configured secret engine.
## Examples
Returns the response from vault, which is typically an empty map. See Secret
Engine Adapter options for further configuration.
{:ok, %{} }} = Vault.delete(vault,"secret/path/to/write")
{:error, ["Key not found"]} = Vault.list(vault,"secret/bad/path/")
"""
@spec delete(t, String.t()) :: {:ok, map} | {:error, term}
@spec delete(t, String.t(), keyword) :: {:ok, map} | {:error, term}
def delete(vault, path, options \\ [])
def delete(%__MODULE__{engine: _, http: nil}, _path, _options),
do: {:error, ["http client not set"]}
def delete(%__MODULE__{engine: nil, http: _}, _path, _options),
do: {:error, ["secret engine not set"]}
def delete(%__MODULE__{engine: engine} = vault, path, options) do
engine.delete(vault, String.trim_leading(path, "/"), options)
end
@doc """
Make an HTTP request against your Vault instance, with the current Vault
token.
## Examples
This library doesn't cover every vault API, but this can help fill some of
the gaps, and removing some boilerplate around token management, and JSON
parsing.
It can also be handy for renewing dynamic secrets, if you're using the AWS
Secret backend.
Requests can take the following options a Keyword List.
### Options:
* `:query_params` - a keyword list of query params for the request. Do
**not** include query params on the path.
* `:body` - Map. The body for the request
* `:headers` - Keyword list. The headers for the request
* `:version` - String. The vault api version - defaults to "v1"
### General Example
Here's a generic example for making a request:
vault = Vault.new(
http: Vault.HTTP.Tesla,
host: "http://localhost",
token: "token"
token_expires_in: NaiveDateTime.utc_now()
)
Vault.request(vault, :post, "path/to/call", [ body: %{ "foo" => "bar"}])
# POST to http://localhost/v1/path/to/call
# with headers: {"X-Vault-Token", "token"}
# and a JSON payload of: "{ 'foo': 'bar'}"
### AWS lease renewal
A quick example of renewing a lease.
vault = Vault.new(
http: Vault.HTTP.Tesla,
host: "http://localhost",
token: "token"
token_expires_in: NaiveDateTime.utc_now()
)
body = %{lease_id: lease, increment: increment}
{:ok, response} = Vault.request(vault, request(:put, "sys/leases/renew", [body: body])
"""
@methods [:get, :put, :post, :patch, :head, :delete]
@spec request(t, method, String.t()) :: {:ok, term} | {:error, list()}
@spec request(t, method, String.t(), keyword) :: {:ok, term} | {:error, list()}
def request(vault, method, path, options \\ [])
def request(%__MODULE__{http: nil}, _method, _path, _options),
do: {:error, ["http client not set."]}
def request(%__MODULE__{host: nil}, _method, _path, _options),
do: {:error, ["host not set."]}
def request(%__MODULE__{}, method, _path, _options) when method not in @methods,
do: {:error, ["invalid method. Must be one of: #{inspect(@methods)}"]}
def request(%__MODULE__{} = vault, method, path, options),
do: Vault.HTTP.request(vault, method, path, options)
end