Skip to main content

lib/break_glass/supervisor.ex

defmodule BreakGlass.Supervisor do
  @moduledoc """
  OTP supervisor for the `break_glass_ex` library.

  Add this supervisor to your host application's supervision tree **before**
  your endpoint or router to ensure all ETS tables exist before the first
  authentication request:

      children = [
        # ... database repos, etc. ...
        {BreakGlass.Supervisor, []},
        MyAppWeb.Endpoint
      ]

  The supervisor starts `RateLimiter`, `TokenStore`, and `OtpStore` in that
  order using a `:one_for_one` strategy. It validates required configuration
  keys at startup and raises `ArgumentError` if any are missing, preventing the
  application from booting in a misconfigured state.

  ## Required Configuration

  The following keys must be present under `config :break_glass_ex`:

  - `:email` — the break-glass email address (`String.t()`)
  - `:password_hash` — the bcrypt hash of the break-glass password (`String.t()`)
  - `:user_provider` — the module implementing `BreakGlass.UserProvider` (`module()`)

  If any required key is absent, an `ArgumentError` is raised listing **all**
  missing keys before any child GenServer is started:

      ArgumentError: BreakGlass: missing required configuration keys: [:email, :password_hash].
      Add them under `config :break_glass_ex` in your runtime.exs.

  ## Optional Configuration

  - `:allowed_ips` — list of exact IPs or CIDR ranges allowed to authenticate
    (default: `["127.0.0.1", "::1"]`). If absent, a `Logger.warning` is emitted
    at startup.
  """

  use Supervisor

  require Logger

  @required_keys [:email, :password_hash, :user_provider]

  # ---------------------------------------------------------------------------
  # Public API
  # ---------------------------------------------------------------------------

  @doc """
  Starts the `BreakGlass.Supervisor` and links it to the calling process.

  `opts` are passed through to `Supervisor.start_link/3` but are otherwise
  unused by the supervisor itself.
  """
  @spec start_link(keyword()) :: Supervisor.on_start()
  def start_link(opts \\ []) do
    Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
  end

  # ---------------------------------------------------------------------------
  # Supervisor callbacks
  # ---------------------------------------------------------------------------

  @impl true
  def init(_opts) do
    config = Application.get_all_env(:break_glass_ex)

    validate_config!(config)
    warn_if_no_allowed_ips(config)

    children = [
      BreakGlass.RateLimiter,
      BreakGlass.TokenStore,
      BreakGlass.OtpStore
    ]

    Supervisor.init(children, strategy: :one_for_one)
  end

  # ---------------------------------------------------------------------------
  # Private helpers
  # ---------------------------------------------------------------------------

  defp validate_config!(config) do
    config_keys = Keyword.keys(config)

    missing = Enum.reject(@required_keys, &(&1 in config_keys))

    unless missing == [] do
      raise ArgumentError,
            "BreakGlass: missing required configuration keys: #{inspect(missing)}. " <>
              "Add them under `config :break_glass_ex` in your runtime.exs."
    end
  end

  defp warn_if_no_allowed_ips(config) do
    unless Keyword.has_key?(config, :allowed_ips) do
      Logger.warning(
        "[BreakGlass] :allowed_ips is not configured — defaulting to localhost " <>
          "(127.0.0.1 and ::1). Set `config :break_glass_ex, allowed_ips: [...]` " <>
          "in your runtime.exs to restrict access to specific IPs or CIDR ranges."
      )
    end
  end
end