lib/vault.ex

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