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