Skip to main content

lib/break_glass/notifier.ex

defmodule BreakGlass.Notifier do
  @moduledoc """
  Handles out-of-band alerting via email and/or webhook after a successful
  break-glass login, and delivers OTP codes to the break-glass email address.

  Both `send_otp/3` and `alert/1` are intended to be called inside
  `Task.start/1` by the `BreakGlass` façade (fire-and-forget). Alert delivery
  failure never prevents a session from being established; all delivery errors
  are logged at `Logger.error` level.

  ## Configuration

  All keys live under `config :break_glass_ex`:

  - `:mailer` — Swoosh mailer module for email delivery (required for email alerting)
  - `:from_email` — sender address for outbound emails
  - `:alert_emails` — list of recipient addresses for break-glass alert emails
  - `:alert_webhook_url` — URL to POST a JSON alert payload on successful login
  - `:dev_otp_log` — when `true`, logs the OTP at `Logger.warning` level to aid
    local development when using an in-memory mailer

  ## Functions

  - `send_otp/3` — delivers the OTP code to the break-glass email address
  - `alert/1` — sends alert emails and/or a webhook POST on successful login

  ## Requirements

  9.1, 9.2, 9.3, 9.4, 9.5, 9.6, 9.7, 9.8, 9.9, 9.10
  """

  require Logger

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

  @doc """
  Delivers the OTP code to the break-glass email address.

  Composes a `Swoosh.Email` using the configured `:mailer` and `:from_email`,
  then delivers it via `mailer.deliver/1`. On delivery error the error is logged
  at `Logger.error` level and `:ok` is returned (fire-and-forget contract).

  When `config :break_glass_ex, dev_otp_log: true` is set, the OTP code is also
  emitted at `Logger.warning` level to aid local development when using an
  in-memory mailer.

  Always returns `:ok`.
  """
  @spec send_otp(to :: String.t(), otp :: String.t(), ip :: String.t()) :: :ok
  def send_otp(to, otp, ip) do
    if Application.get_env(:break_glass_ex, :dev_otp_log, false) do
      Logger.warning("[BreakGlass] DEV OTP for #{to} from #{ip}: #{otp}")
    end

    case build_mailer() do
      nil ->
        Logger.error("[BreakGlass] send_otp failed: no :mailer configured")
        :ok

      mailer ->
        from = Application.get_env(:break_glass_ex, :from_email, "noreply@break-glass")

        email =
          Swoosh.Email.new()
          |> Swoosh.Email.to(to)
          |> Swoosh.Email.from(from)
          |> Swoosh.Email.subject("[BreakGlass] Your one-time authentication code")
          |> Swoosh.Email.text_body(
            "Your break-glass OTP is: #{otp}\n\n" <>
              "This code expires in 10 minutes.\n" <>
              "Login attempted from IP: #{ip}"
          )

        case mailer.deliver(email) do
          {:ok, _} ->
            :ok

          {:error, reason} ->
            Logger.error("[BreakGlass] send_otp delivery failed to #{to}: #{inspect(reason)}")
            :ok
        end
    end
  end

  @doc """
  Sends break-glass alert notifications after a successful login.

  ## Email alerting

  For each address in the `:alert_emails` config list, an alert email is
  delivered. If delivery to a single recipient fails, the error is logged at
  `Logger.error` level and delivery continues to the remaining recipients.

  If `:alert_emails` is an empty list, a `Logger.warning` is emitted and email
  notification is skipped.

  If `:alert_emails` is not configured (absent), email alerting is skipped
  silently.

  ## Webhook alerting

  If `:alert_webhook_url` is a non-empty string, a JSON POST is sent to that
  URL via `Req.post/2`. The payload contains the fields `event`, `ip`,
  `timestamp`, `severity`, and `message`.

  If the POST returns a non-2xx status, the error is logged at `Logger.error`
  level. If `:alert_webhook_url` is `nil` or absent, the webhook step is
  skipped silently.

  Always returns `:ok`.
  """
  @spec alert(ip :: String.t()) :: :ok
  def alert(ip) do
    send_alert_emails(ip)
    send_alert_webhook(ip)
    :ok
  end

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

  defp send_alert_emails(ip) do
    case Application.get_env(:break_glass_ex, :alert_emails) do
      nil ->
        # Not configured — skip silently
        :ok

      [] ->
        Logger.warning("[BreakGlass] :alert_emails is empty — no alert emails will be sent")
        :ok

      emails when is_list(emails) ->
        mailer = build_mailer()
        from = Application.get_env(:break_glass_ex, :from_email, "noreply@break-glass")

        Enum.each(emails, fn recipient ->
          deliver_alert_email(mailer, from, recipient, ip)
        end)
    end
  end

  defp deliver_alert_email(nil, _from, recipient, _ip) do
    Logger.error("[BreakGlass] alert email to #{recipient} skipped: no :mailer configured")
  end

  defp deliver_alert_email(mailer, from, recipient, ip) do
    email =
      Swoosh.Email.new()
      |> Swoosh.Email.to(recipient)
      |> Swoosh.Email.from(from)
      |> Swoosh.Email.subject("[SECURITY ALERT] Break-glass login detected")
      |> Swoosh.Email.text_body(
        "A break-glass login was performed from IP: #{ip}\n\n" <>
          "If this was not authorised, investigate immediately."
      )

    case mailer.deliver(email) do
      {:ok, _} ->
        :ok

      {:error, reason} ->
        Logger.error(
          "[BreakGlass] alert email delivery failed to #{recipient}: #{inspect(reason)}"
        )
    end
  end

  defp send_alert_webhook(ip) do
    case Application.get_env(:break_glass_ex, :alert_webhook_url) do
      url when is_binary(url) and byte_size(url) > 0 ->
        payload = %{
          event: "break_glass_login",
          ip: ip,
          timestamp: DateTime.utc_now() |> DateTime.to_iso8601(),
          severity: "critical",
          message: "Break-glass login from #{ip}"
        }

        case Req.post(url, json: payload) do
          {:ok, %{status: status}} when status >= 200 and status < 300 ->
            :ok

          {:ok, %{status: status}} ->
            Logger.error("[BreakGlass] webhook POST to #{url} returned non-2xx status: #{status}")

          {:error, reason} ->
            Logger.error("[BreakGlass] webhook POST to #{url} failed: #{inspect(reason)}")
        end

      _ ->
        # nil, absent, or empty string — skip silently
        :ok
    end
  end

  defp build_mailer do
    Application.get_env(:break_glass_ex, :mailer)
  end
end