lib/vault/engine/generic.ex

defmodule Vault.Engine.Generic do
  @moduledoc """
  A generic Vault.Engine adapter. Most of the vault secret engines don't use a
  wildly different API, and can be handled with a single adapter.

  ## Request Details
  By default, `read` runs a GET request, `write` does a POST, `list` does a GET
  with an appended `?list=true`, and `delete` runs a DELETE. The options below
  should give you additional flexibility.

  ### Request Options:
  - :method - one of :get, :put, :post, :options, :patch, :head
  - :full_response - if `true`, returns the full response body on success, rather than just the `data` key. Defaults to `false`,
  - :query_params - query params for the request. Defaults to `%{}` (no params)
  - :body - body to be sent along with the request. Defaults to `%{}` (no body) on read, or the passed in `value` on write

  ## Examples

  Create a generic vault client:

    {:ok, vault } =
      Vault.new(
        host: System.get_env("VAULT_ADDR"),
        auth: Vault.Auth.Token,
        engine: Vault.Engine.Generic,
        http: Vault.HTTP.Tesla,
      ) |> Vault.auth(%{token: "token"})

  Read/Write from the cubbyhole secret engine.

    {:ok, _data} = Vault.write(vault, "cubbyhole/hello",  %{"foo" => "bar"})
    {:ok, %{"foo" => "bar"}} = Vault.read(vault, "cubbyhole/hello")

  Read/Write from the ssh secret engine.

    # create a key
    {:ok, _} = Vault.write(vault, "ssh/keys/test", %{key: key})

    # create a role for that key
    {:ok, _} =
      Vault.write(vault, "ssh/roles/test", %{
        key: "test",
        key_type: "dynamic",
        default_user: "tester",
        admin_user: "admin_tester"
      })

    # read a role, and return the full response
    {:ok, %{ "data" => data } } =
      Vault.read(vault, "ssh-client-signer/roles/test", full_response: true)

  Options:
  - :method - one of :get, :put, :post, :options, :patch, :head
  - :full_response - if `true`, returns the full response body on success, rather than just the `data` key. Defaults to `false`,
  - :params - query params for the request. Defaults to `%{}` (no params)
  - :body - body to be sent along with the request. Defaults to `%{}` (no body) on read, or the passed in `value` on write
  """

  @behaviour Vault.Engine.Adapter
  @type vault :: Vault.t()
  @type path :: String.t()
  @type options :: Keyword.t()
  @type token :: String.t()
  @type value :: map()
  @type errors :: list()

  @doc """
  Gets a value from vault. Defaults to a GET request against the current path.
  See `option` details above for full configuration.
  """
  @impl true
  def read(vault, path, options \\ []) do
    options = Keyword.merge([method: :get], options)
    request(vault, path, %{}, options)
  end

  @doc """
  Puts a value in vault. Defaults to a POST request against the provided path.
  See `options` details above for full configuration.
  """
  @impl true
  def write(vault, path, value, options \\ []) do
    options = Keyword.merge([method: :post], options)
    request(vault, path, value, options)
  end

  @doc """
  Lists secrets at a path. Defaults to a GET request against the provided path,
  with a query param of ?list=true.

  See `options` details above for full configuration.

  ## Examples

  ```
  {:ok, %{
      "keys"=> ["foo", "foo/"]
    }
  } = Vault.Engine.Generic.list(vault, "path/to/list/", [full_response: true])
  ```
  With the full Response:

  ```
  {:ok, %{
      "data" => %{
        "keys"=> ["foo", "foo/"]
      },
    }
  }  = Vault.Engine.Generic.list(vault, "path/to/list/", [full_response: true])
  ```
  """
  @impl true
  def list(vault, path, options \\ []) do
    options = Keyword.merge([method: :get, query_params: %{list: true}], options)
    request(vault, path, %{}, options)
  end

  @impl true
  def delete(vault, path, options \\ []) do
    options = Keyword.merge([method: :delete], options)
    request(vault, path, nil, options)
  end

  defp request(client, path, value, options) do
    method = Keyword.get(options, :method, :post)
    body = Keyword.get(options, :body, value)
    query_params = Keyword.get(options, :query_params, [])
    full_response = Keyword.get(options, :full_response, false)

    with {:ok, body} <-
           Vault.HTTP.request(client, method, path, body: body, query_params: query_params) do
      case body do
        nil ->
          {:ok, %{}}

        "" ->
          {:ok, %{}}

        %{"errors" => []} ->
          {:error, ["Key not found"]}

        %{"errors" => messages} ->
          {:error, messages}

        %{} = data when full_response == true ->
          {:ok, data}

        %{"data" => data} ->
          {:ok, data}

        otherwise ->
          {:error, ["Unknown response from vault", inspect(otherwise)]}
      end
    else
      {:error, reason} ->
        {:error, ["Http Adapter error", inspect(reason)]}
    end
  end
end