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