lib/providers/google_secret_manager.ex

defmodule ExSecrets.Providers.GoogleSecretManager do
  @moduledoc """
  Google Secret Manager provider provides secrets from an Google Secret Manager through a rest API.

  ### Configuration

  Using the Service Account Credentials File
  ```
  Application.put_env(:ex_secrets, :providers, %{
      google_secret_manager: %{
        service_account_credentials_path: ".temp/cred.json"
      }
    })

  ```

  Using the json file contents

  ```
  Application.put_env(:ex_secrets, :providers, %{
      google_secret_manager: %{
        service_account_credentials: %{
        "type" => "service_account",
        "project_id" => "project-id",
        "private_key_id" => "keyid",
        "private_key" => "-----BEGIN PRIVATE KEY-----...-----END PRIVATE KEY-----\n",
        "client_email" => "secretaccess@project-id.iam.gserviceaccount.com",
        "client_id" => "client-id",
        "auth_uri" => "https://accounts.google.com/o/oauth2/auth",
        "token_uri" => "https://oauth2.googleapis.com/token",
        "auth_provider_x509_cert_url" => "https://www.googleapis.com/oauth2/v1/certs",
        "client_x509_cert_url" => "https://www.googleapis.com/robot/v1/metadata/x509/secretaccess%40project-id.iam.gserviceaccount.com",
        "universe_domain" => "googleapis.com"
        }
      }
    })
  ```

  CRC32C Verification

  When google returns the CRC32C value, the provider will verify the value with the data returned from the API. If the values do not match, the provider will return an error.
  The provider uses the crc32cer library https://hex.pm/packages/crc32cer to verify the CRC32C value.
  """

  use ExSecrets.Providers.Base

  alias ExSecrets.Utils.Config

  @process_name :ex_secrets_google_secret_manager
  @token_headers %{"Content-Type" => "application/x-www-form-urlencoded"}
  @token_uri "https://oauth2.googleapis.com/token"

  @secrets_base_uri "https://secretmanager.googleapis.com/v1/projects/PROJECT_NAME/secrets/SECRET_NAME/versions/latest:access"

  def init(_) do
    with {:ok, cred} <- get_service_account_credentials(),
         {:ok, data} <- get_access_token(cred) do
      {:ok, data |> Map.put("issued_at", get_current_epoch())}
    else
      _ ->
        {:ok, %{}}
    end
  end

  def reset() do
    :ok
  end

  def get(name) do
    with process when not is_nil(process) <-
           GenServer.whereis(@process_name) do
      GenServer.call(@process_name, {:get, name})
    else
      nil ->
        case get_secret(name, %{}, nil) do
          {:ok, value, _} -> value
          _ -> nil
        end
    end
  end

  def set(name, value) do
    name = name |> String.split("_") |> Enum.join("-")

    with process when not is_nil(process) <-
           GenServer.whereis(@process_name) do
      GenServer.call(@process_name, {:set, name, value})
    else
      nil ->
        case set_secret(name, value, %{}, nil) do
          {:ok, _value, _} ->
            :ok

          _ ->
            :error
        end
    end
  end

  def handle_call({:get, name}, _from, state) do
    case get_secret(name, state, get_current_epoch()) do
      {:ok, secret, state} -> {:reply, secret, state}
      _ -> {:reply, nil, state}
    end
  end

  def handle_call({:set, name, value}, _from, state) do
    case set_secret(name, value, state, get_current_epoch()) do
      {:ok, _secret, state} -> {:reply, :ok, state}
      _ -> {:reply, :error, state}
    end
  end

  defp get_secret(
         name,
         %{"access_token" => access_token, "issued_at" => issued_at, "expires_in" => expires_in} =
           state,
         current_time
       )
       when issued_at + expires_in - current_time > 5 do
    with {:ok, value} <- get_secret_call(name, access_token, state.cred),
         true <- is_binary(value) do
      {:ok, value, state}
    else
      _ -> {:error, "Failed to get secret"}
    end
  end

  defp get_secret(name, state, _) do
    with {:ok, cred} <- get_service_account_credentials(),
         {:ok, %{"access_token" => access_token} = new_state} <- get_access_token(cred),
         {:ok, value} <- get_secret_call(name, access_token, cred) do
      {:ok, value, state |> Map.merge(new_state)}
    else
      _ -> {:error, "Failed to get secret"}
    end
  end

  defp set_secret(
         name,
         value,
         %{"access_token" => access_token, "issued_at" => issued_at, "expires_in" => expires_in} =
           state,
         current_time
       )
       when issued_at + expires_in - current_time > 5 do
    with {:ok, value} <- set_secret_call(name, value, access_token, state.cred),
         true <- is_binary(value) do
      {:ok, value, state}
    else
      _ -> {:error, "Failed to get secret"}
    end
  end

  defp set_secret(name, value, state, _) do
    with {:ok, cred} <- get_service_account_credentials(),
         {:ok, %{"access_token" => access_token} = new_state} <- get_access_token(cred),
         {:ok, value} <- set_secret_call(name, value, access_token, cred) do
      {:ok, value, state |> Map.merge(new_state)}
    else
      _ -> {:error, "Failed to get secret"}
    end
  end

  defp get_secret_call(name, access_token, cred) do
    client = http_adpater()

    url =
      @secrets_base_uri
      |> String.replace("PROJECT_NAME", cred["project_id"])
      |> String.replace("SECRET_NAME", name)

    with {:ok, %{body: body, status_code: 200}} <-
           client.get(url, %{"Authorization" => "Bearer #{access_token}"}),
         {:ok, %{"payload" => %{"data" => data} = payload}} <- Poison.decode(body),
         {:ok, value} <- Base.decode64(data),
         true <- verify_crc32c(value, payload["dataCrc32c"]) do
      {:ok, value}
    else
      _ -> {:error, "Failed to get secret"}
    end
  end

  defp set_secret_call(name, value, access_token, cred) do
    client = http_adpater()

    payload = %{
      name: "projects/#{cred["project_id"]}/secrets/#{name}",
      replication: %{
        automatic: %{}
      }
    }

    url =
      "https://secretmanager.googleapis.com/v1/projects/#{cred["project_id"]}/secrets?secretId=#{name}"

    with {:ok, %{status_code: status}} when status in [200, 409] <-
           client.post(url, Poison.encode!(payload), %{
             "Authorization" => "Bearer #{access_token}",
             "content-type" => "application/json"
           }),
         {:ok, %{status_code: 200}} <- set_secret_version_call(name, value, access_token, cred) do
      {:ok, value}
    else
      _ ->
        {:error, "Failed to create secret"}
    end
  end

  defp set_secret_version_call(name, value, access_token, cred) do
    client = http_adpater()

    payload = %{
      payload: %{
        data: Base.encode64(value)
      }
    }

    url =
      "https://secretmanager.googleapis.com/v1/projects/#{cred["project_id"]}/secrets/#{name}:addVersion"

    client.post(
      url,
      Poison.encode!(payload),
      %{
        "Authorization" => "Bearer #{access_token}",
        "content-type" => "application/json"
      },
      timeout: 30_000
    )
  end

  defp get_access_token(cred) do
    client = http_adpater()

    token_req_body = %{
      grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
      assertion: jwt(cred)
    }

    with {:ok, %{body: body, status_code: 200}} <-
           client.post(@token_uri, URI.encode_query(token_req_body), @token_headers),
         {:ok, data} <- Poison.decode(body) do
      {:ok, data |> Map.put("issued_at", get_current_epoch())}
    else
      _ ->
        {:error, "Failed to get access token"}
    end
  end

  defp get_service_account_credentials() do
    path = Config.provider_config_value(:google_secret_manager, :service_account_credentials_path)
    cred = Config.provider_config_value(:google_secret_manager, :service_account_credentials)

    cond do
      is_map(cred) ->
        {:ok, cred}

      is_binary(path) ->
        get_cred_from_path(path)

      true ->
        {:error, :no_auth}
    end
  end

  defp get_cred_from_path(path) do
    with {:ok, s} <- File.read(path),
         {:ok, cred} <- Poison.decode(s) do
      {:ok, cred}
    else
      _ -> {:error, :no_auth}
    end
  end

  defp jwt(cred) do
    t = DateTime.to_unix(DateTime.utc_now())

    signer = Joken.Signer.create("RS256", %{"pem" => cred["private_key"]})

    claims = %{
      "iss" => cred["client_email"],
      "sub" => cred["client_email"],
      "aud" => "https://oauth2.googleapis.com/token",
      "exp" => t + 1200,
      "iat" => t,
      "scope" => "https://www.googleapis.com/auth/cloud-platform"
    }

    case Joken.encode_and_sign(claims, signer) do
      {:ok, jwt, _} -> jwt
      _ -> ""
    end
  end

  defp http_adpater() do
    Application.get_env(:ex_secrets, :http_adapter, HTTPoison)
  end

  defp get_current_epoch() do
    System.system_time(:second)
  end

  def process_name() do
    @process_name
  end

  defp verify_crc32c(_, _), do: true

end