Packages

phoenix_kit

2.60.2
2.60.3 2.60.2 2.60.1 2.60.0 2.59.0 2.58.0 2.57.1 2.57.0 2.56.1 2.56.0 2.55.1 2.55.0 2.54.2 2.54.1 2.54.0 2.53.0 2.52.2 2.52.1 2.52.0 2.51.0 2.50.0 2.49.1 2.49.0 2.48.0 2.47.0 2.46.0 2.45.0 2.44.0 2.43.1 2.43.0 2.42.1 2.42.0 2.41.6 2.41.4 2.41.3 2.41.2 2.41.1 2.41.0 2.40.1 2.40.0 2.39.0 2.38.1 2.38.0 2.37.5 2.37.4 2.37.3 2.37.2 2.37.1 2.37.0 2.36.1 2.36.0 2.35.0 2.34.0 2.33.0 2.32.1 2.32.0 2.31.1 2.31.0 2.30.0 2.29.1 2.29.0 2.28.2 2.28.1 2.28.0 2.27.2 2.27.1 2.27.0 2.26.1 2.26.0 2.25.0 2.24.0 2.23.3 2.23.2 2.23.1 2.23.0 2.22.24 2.22.23 2.22.22 2.22.21 2.22.20 2.22.19 2.22.18 2.22.17 2.22.16 2.22.15 2.22.14 2.22.13 2.22.12 2.22.11 2.22.10 2.22.9 2.22.8 2.22.7 2.22.6 2.22.5 2.22.4 2.22.3 2.22.2 2.22.1 2.22.0 2.21.5 2.21.4 2.21.3 2.21.2 2.21.1 2.21.0 2.20.0 2.19.0 2.18.1 2.18.0 2.17.0 2.16.0 2.15.1 2.15.0 2.14.2 2.14.1 2.14.0 2.13.19 2.13.18 2.13.17 2.13.16 2.13.15 2.13.13 2.13.12 2.13.11 2.13.10 2.13.9 2.13.8 2.13.7 2.13.6 2.13.5 2.13.4 2.13.3 2.13.2 2.13.1 2.13.0 2.12.1 2.12.0 2.11.0 2.10.0 2.9.0 2.8.1 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.0 2.0.1 2.0.0 1.7.236 1.7.235 1.7.234 1.7.233 1.7.232 1.7.231 1.7.230 1.7.229 1.7.228 1.7.227 1.7.226 1.7.225 1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib modules storage bucket_credentials.ex
Raw

lib/modules/storage/bucket_credentials.ex

defmodule PhoenixKit.Modules.Storage.BucketCredentials do
  @moduledoc """
  Moves a bucket's cloud keys into the Integrations system.

  A bucket used to carry its own `access_key_id` / `secret_access_key`. Keys now
  belong to an `object_storage` connection (**Settings → Integrations**), which
  buckets reference by `integration_uuid`: one place to rotate or revoke a key,
  one encrypted copy, and the same connection can serve several buckets.

  A bucket that still carries its own keys is **legacy** and keeps working for
  as long as it is left alone: `Providers.S3.resolve_credentials/1` reads either
  source. Nothing here runs by itself. An operator moves a bucket, or all of
  them, on purpose, and `move_to_integration/2` refuses to clear a key it could
  not read back through the new connection.

  The bucket keeps its own `region` and `endpoint`. They are not secrets, and
  reading them from the connection would put a settings lookup behind every
  public URL a file grid builds.
  """

  alias PhoenixKit.Integrations
  alias PhoenixKit.Modules.Storage
  alias PhoenixKit.Modules.Storage.Bucket
  alias PhoenixKit.Modules.Storage.Providers.S3

  @provider "object_storage"

  @type reason ::
          :not_legacy
          | :unreadable_credentials
          | :credentials_mismatch
          | Ecto.Changeset.t()
          | term()

  @doc "The Integrations provider key a bucket's keys move into."
  @spec provider_key() :: String.t()
  def provider_key, do: @provider

  @doc """
  Whether `bucket` is a cloud bucket that still carries its own keys, with no
  connection set.
  """
  @spec legacy?(Bucket.t()) :: boolean()
  def legacy?(%Bucket{} = bucket) do
    Bucket.cloud?(bucket) and not present?(bucket.integration_uuid) and
      (present?(bucket.access_key_id) or present?(bucket.secret_access_key))
  end

  @doc "Every bucket that still carries its own keys."
  @spec legacy_buckets() :: [Bucket.t()]
  def legacy_buckets, do: Enum.filter(Storage.list_buckets(), &legacy?/1)

  @doc """
  Moves one bucket's keys into a system `object_storage` connection.

  A connection that already holds the same key pair is reused, so buckets in
  one account share a single connection. Otherwise one is created, named after
  the bucket. The bucket's own keys are cleared only after the connection is
  read back and returns exactly the keys the bucket had; any failure rolls the
  whole move back, the new connection included.

  Options: `:actor_uuid` (recorded on the connection's and bucket's activity entries).
  """
  @spec move_to_integration(Bucket.t(), keyword()) :: {:ok, Bucket.t()} | {:error, reason()}
  def move_to_integration(%Bucket{} = bucket, opts \\ []) do
    actor_uuid = Keyword.get(opts, :actor_uuid)
    repo = PhoenixKit.RepoHelper.repo()

    repo.transaction(fn ->
      with :ok <- ensure_legacy(bucket),
           {:ok, keys} <- read_keys(bucket),
           {:ok, uuid} <- find_or_create_connection(bucket, keys, actor_uuid),
           :ok <- verify_round_trip(bucket, uuid, keys),
           {:ok, moved} <- clear_keys(bucket, uuid, opts) do
        moved
      else
        {:error, reason} -> repo.rollback(reason)
      end
    end)
  end

  @doc """
  Moves every legacy bucket. Returns `[{bucket, {:ok, moved} | {:error, reason}}]`,
  one entry per bucket that was tried; a bucket that fails does not stop the rest.
  """
  @spec move_all_to_integrations(keyword()) :: [
          {Bucket.t(), {:ok, Bucket.t()} | {:error, reason()}}
        ]
  def move_all_to_integrations(opts \\ []) do
    Enum.map(legacy_buckets(), &{&1, move_to_integration(&1, opts)})
  end

  defp ensure_legacy(bucket), do: if(legacy?(bucket), do: :ok, else: {:error, :not_legacy})

  # The plaintext pair, from the one place that produces it. A secret that
  # cannot be decrypted (a rotated `secret_key_base`) must never be "moved":
  # that would replace a key that is merely unreadable now with a blank one.
  defp read_keys(bucket) do
    case S3.resolve_credentials(bucket) do
      {key, secret} when is_binary(key) and key != "" and is_binary(secret) and secret != "" ->
        {:ok, {key, secret}}

      _ ->
        {:error, :unreadable_credentials}
    end
  end

  defp find_or_create_connection(bucket, {key, secret}, actor_uuid) do
    existing =
      Enum.find(Integrations.list_connections(@provider), fn %{data: data} ->
        data["access_key"] == key and data["secret_key"] == secret
      end)

    case existing do
      %{uuid: uuid} -> {:ok, uuid}
      nil -> create_connection(bucket, {key, secret}, actor_uuid)
    end
  end

  defp create_connection(bucket, {key, secret}, actor_uuid) do
    attrs =
      Map.reject(
        %{
          "access_key" => key,
          "secret_key" => secret,
          "region" => bucket.region,
          "endpoint" => bucket.endpoint
        },
        fn {_field, value} -> not present?(value) end
      )

    with {:ok, %{uuid: uuid}} <- Integrations.add_connection(@provider, bucket.name, actor_uuid),
         {:ok, _saved} <- Integrations.save_setup(uuid, attrs, actor_uuid) do
      {:ok, uuid}
    end
  end

  # Reads the keys back through the connection the bucket is about to point at.
  defp verify_round_trip(bucket, uuid, keys) do
    through_connection = %{
      bucket
      | integration_uuid: uuid,
        access_key_id: nil,
        secret_access_key: nil
    }

    if S3.resolve_credentials(through_connection) == keys,
      do: :ok,
      else: {:error, :credentials_mismatch}
  end

  defp clear_keys(bucket, uuid, opts) do
    Storage.update_bucket(
      bucket,
      %{integration_uuid: uuid, access_key_id: nil, secret_access_key: nil},
      opts
    )
  end

  defp present?(nil), do: false
  defp present?(""), do: false
  defp present?(_value), do: true
end