lib/vault/engine/kv/v2.ex

defmodule Vault.Engine.KVV2 do
  @moduledoc """
  Get and put secrets using the v2 KV (versioned) secrets engine

  See: [Vault Docs](https://www.vaultproject.io/api/secret/kv/kv-v2.html) for details.
  """
  @behaviour Vault.Engine.Adapter

  @type vault :: Vault.t()
  @type path :: String.t()
  @type version :: integer
  @type token :: String.t()
  @type value :: map()
  @type errors :: list()
  @type options :: list()

  @doc """
  Get a secret from vault. Optionally supply a version, otherwise gets latest
  value.

  ## Examples

  Fetch a value at a specific version, with the `:version` option.

  ```
  {:ok, %{"foo" => "bar"}} = Vault.Engine.KVV2.read(vault, "secret/to/get, [version: 1])
  {:ok, %{"bar" => "baz"}} = Vault.Engine.KVV2.read(vault, "secret/to/get, [version: 2])
  ```

  Because of the nature of soft deletes,fetching soft-deleted secrets will return
  an error.

  ```
  {:error, ["Key not found"]} = Vault.Engine.KVV2.read(vault, "soft/deleted/secret", [version: 1])
  ```

  However, if you wish to see the metadata or additional values, setting full_response to `true` will return
  can return a soft deleted key as a success.
  ```
  {:ok,  %{
      "auth" => nil,
      "data" => %{
        "data" => nil,
        "metadata" => %{
          "created_time" => "2018-11-21T19:49:49.339727561Z",
          "deletion_time" => "2018-11-21T19:49:49.353904424Z",
          "destroyed" => false,
          "version" => 1
        }
      },
      "lease_duration" => 0,
      "lease_id" => "",
      "renewable" => false,
      "request_id" => "e289ff31-609f-44fa-7161-55c63fda3d43",
      "warnings" => nil,
      "wrap_info" => nil
    }
  } = Vault.Engine.KVV2.read(vault, "soft/deleted/secret", [version: 1, full_response: true])

  ```

  Options:
  - `version: integer` - the version you want to return.
  - `full_response: boolean` - get the whole response back on success, not just the data field
  """
  @impl true
  @spec read(vault, path, options) :: {:ok, value} | {:error, errors}
  def read(vault, path, options \\ []) do
    path = v2_data_path(path) <> with_version(options)
    full_response = Keyword.get(options, :full_response, false)
    # normalize nested response.
    case Vault.Engine.Generic.read(vault, path, options) do
      {:ok, %{} = data} when full_response == true ->
        {:ok, data}

      {:ok, %{"data" => nil}} ->
        {:error, ["Key not found"]}

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

      otherwise ->
        otherwise
    end
  end

  @doc """
  Put a secret in vault, on a given path.

  ## Examples

  Write a new version:
  ```
  {:ok, %{}} = Vault.Engine.Generic.write(vault, "path/to/write", %{ foo: "bar" })
  ```

  Check and set  - see [Vault Docs](https://www.vaultproject.io/api/secret/kv/kv-v2.html#create-update-secret)
  for details

  ```
  # write only if the value doesn't exist
  {:ok, response } = Vault.Engine.Generic.write(vault, "path/to/write", %{ foo: "bar" }, [cas: 0])

  # write only if the cas matches the current secret version
  {:ok, response } = Vault.Engine.Generic.write(vault, "path/to/write", %{ foo: "bar" }, [cas: 1])

  ```

  Get the full response body from vault:

  ```
    {:ok, %{
        "data" => %{
          "created_time" => "2018-03-22T02:36:43.986212308Z",
          "deletion_time" => "",
          "destroyed" => false,
          "version" => 1
        },
      }
    } = Vault.Engine.Generic.write(vault, "path/to/write", %{ foo: "bar" }, [full_response: true])

  ```

  ### Options
  - `cas: integer` set a check-and-set value
  - `full_response: boolean` - get the whole response back on success, not just the data field
  """
  @impl true
  @spec write(vault, path, value, options) :: {:ok, map()} | {:error, errors}
  def write(vault, path, value, options \\ []) do
    value =
      if cas = Keyword.get(options, :cas, false),
        do: %{data: value, options: %{cas: cas}},
        else: %{data: value}

    Vault.Engine.Generic.write(vault, v2_data_path(path), value, options)
  end

  @doc """
  This endpoint returns a list of key names at the specified location. Folders are
  suffixed with /. The input must be a folder; list on a file will not return a value.

  ## Examples

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

  ```
  {:ok, %{
      "data" => %{
        "keys"=> ["foo", "foo/"]
      },
    }
  }  = Vault.Engine.KVV2.List(vault, "path/to/list/", [full_response: true])
  ```
  """
  @impl true
  @spec list(vault, path, options) :: {:ok, map()} | {:error, errors}
  def list(vault, path, options \\ []) do
    Vault.Engine.Generic.list(vault, v2_metadata_path(path), options)
  end

  @doc """
  Soft or Hard Delete a versioned secret. Requires a list of versions to be removed. This request
  produces an empty body, so an empty map is returned.

  ## Examples

  Soft delete a version of a secret
  ```
  {:ok, %{
      "data" => nil,
      "metadata" => %{
        "created_time" => "2018-11-21T19:49:49.339727561Z",
        "deletion_time" => "2018-11-21T19:49:49.353904424Z",
        "destroyed" => false,
        "version" => 5
      }
    }
  } = Vault.Engine.KVV2.Delete(vault, "path/to/delete", versions: [5], full_response: true)
  ```

  Hard delete a secret
  {:ok, %{
      "data" => nil,
      "metadata" => %{
        "created_time" => "2018-11-21T19:49:49.339727561Z",
        "deletion_time" => "2018-11-21T19:49:49.353904424Z",
        "destroyed" => true,
        "version" => 5
      }
    }
  } = Vault.Engine.KVV2.Delete(vault, "path/to/delete", versions: [5], destroy: true, full_response: true)

  """
  @impl true
  @spec delete(vault, path, options) :: {:ok, map()} | {:error, errors}
  def delete(vault, path, options \\ []) do
    {destroy, options} = Keyword.pop(options, :destroy)
    {versions, options} = Keyword.pop(options, :versions)
    path = if destroy, do: v2_destroy_path(path), else: v2_delete_path(path)

    case versions do
      value when is_list(value) ->
        options = Keyword.merge([method: :post, body: %{versions: versions}], options)
        Vault.Engine.Generic.delete(vault, path, options)

      _otherwise ->
        {:error, ["A list of versions is required"]}
    end
  end

  defp v2_path(path, prefix) do
    String.split(path, "/", parts: 2) |> Enum.join("/" <> prefix <> "/")
  end

  defp v2_data_path(path), do: v2_path(path, "data")

  defp v2_metadata_path(path), do: v2_path(path, "metadata")

  defp v2_delete_path(path), do: v2_path(path, "delete")

  defp v2_destroy_path(path), do: v2_path(path, "destroy")

  defp with_version([]), do: ""

  defp with_version(options) do
    case Keyword.get(options, :version) do
      nil -> ""
      version -> "?version=#{version}"
    end
  end
end