defmodule ExSecrets.Utils.SecretFetchLimiter do @moduledoc """ Secret fetch limiter module limits the number of secret fetches in a given time. """ require Logger use GenServer @table_name :ex_secrets_fetch_limiter_table @process_name :ex_secrets_fetch_limiter @secret_fetch_limit_timer :timer.seconds(60) @secret_fetch_limit 5 @on_secret_fetch_limit_reached :ignore def start_link(default) when is_list(default) do GenServer.start_link(__MODULE__, [], name: @process_name) end @impl true def init(_) do table = :ets.new(@table_name, [:named_table, :public, :bag, read_concurrency: true]) :timer.send_interval(:timer.seconds(5), :clear) {:ok, %{table: table}} end @impl true def handle_info(:clear, state) do # https://elixirforum.com/t/how-to-delete-ets-records-older-than-xxx/43954 now = current_time() :ets.select_delete(@table_name, [{{:"$1", :"$2"}, [{:<, :"$2", now}], [true]}]) {:noreply, state} end def allow(key, module, function, args) do case {:ets.whereis(@table_name), args} do {_, [_, :system_env]} -> Kernel.apply(module, function, args) {:undefined, _} -> Kernel.apply(module, function, args) {_, _} -> now = current_time() @table_name |> :ets.select([{{key, :"$1"}, [{:>, :"$1", now}], [:"$1"]}]) |> Enum.count() |> Kernel.<=( Application.get_env( :ex_secrets, :secret_fetch_limit, @secret_fetch_limit ) ) |> case do true -> track(key) Kernel.apply(module, function, args) false -> limit_reached(key, [module, function, args]) end end end def track(key) do expires_in = :ets.lookup(@table_name, key) |> Enum.count() |> Kernel.+(1) |> Kernel.*(5) |> Kernel.+( Application.get_env(:ex_secrets, :secret_fetch_limit_timer, @secret_fetch_limit_timer) ) |> :timer.seconds() :ets.insert(@table_name, {key, current_time() + expires_in}) end defp current_time() do System.system_time(:millisecond) end defp limit_reached(key, [module, function, args]) do limit = Application.get_env( :ex_secrets, :secret_fetch_limit, @secret_fetch_limit ) case Application.get_env( :ex_secrets, :on_secret_fetch_limit_reached, @on_secret_fetch_limit_reached ) do :ignore -> Kernel.apply(module, function, args) :warn -> IO.puts( "#{IO.ANSI.yellow()}ExSecrets Warning ==> #{NaiveDateTime.utc_now()} ==> Fetch secret #{key} reached limit #{limit}" ) Kernel.apply(module, function, args) :raise -> raise "Fetch secret #{key} reached limit #{limit}" _ -> IO.puts( "#{IO.ANSI.red()}ExSecrets Warning ==> #{NaiveDateTime.utc_now()} ==> Fetch secret #{key} reached limit #{limit}" ) Kernel.apply(module, function, args) end end end