towerops/lib/towerops/gaiia/webhooks.ex
Graham McIntire bad1590fed
add gaiia webhook listener with per-org signature verification
Stage 3 of Gaiia integration: webhook event processing for real-time
incremental updates from Gaiia. HMAC-SHA256 signature verification
using per-organization secrets stored in integration credentials.
Handles account, billing subscription, and inventory item events.
2026-02-13 10:40:49 -06:00

105 lines
3.1 KiB
Elixir

defmodule Towerops.Gaiia.Webhooks do
@moduledoc """
Processes webhook events from Gaiia.
Handles incremental updates for accounts, billing subscriptions,
and inventory items. Unknown events are logged and ignored for
forward-compatibility.
"""
alias Towerops.Gaiia
require Logger
@doc """
Verify an HMAC-SHA256 signature against the raw request body.
Returns `true` if the signature matches, `false` otherwise.
Uses constant-time comparison to prevent timing attacks.
"""
@spec verify_signature(binary(), binary(), binary()) :: boolean()
def verify_signature(body, signature, secret) do
expected = :hmac |> :crypto.mac(:sha256, secret, body) |> Base.encode16(case: :lower)
Plug.Crypto.secure_compare(expected, signature)
end
@doc """
Process a webhook event from Gaiia.
Returns `:ok` for all events, including unknown ones (forward-compatible).
"""
@spec process_event(String.t(), String.t(), map()) :: :ok
def process_event(organization_id, event, payload) do
case event do
"account." <> _ -> handle_account_event(organization_id, event, payload)
"billing_subscription." <> _ -> handle_subscription_event(organization_id, event, payload)
"inventory_item." <> _ -> handle_inventory_event(organization_id, event, payload)
_ -> Logger.info("Ignoring unknown Gaiia webhook event: #{event}")
end
:ok
end
defp handle_account_event(organization_id, event, %{"data" => data}) do
gaiia_id = data["id"]
case event do
"account.deleted" ->
upsert_account(organization_id, gaiia_id, data, %{status: "deleted"})
_ ->
upsert_account(organization_id, gaiia_id, data, %{})
end
end
defp handle_subscription_event(organization_id, _event, %{"data" => data}) do
attrs = %{
gaiia_id: data["id"],
account_gaiia_id: data["account_id"],
status: data["status"],
product_name: data["product_name"],
mrr_amount: parse_decimal(data["mrr_amount"]),
currency: data["currency"]
}
Gaiia.upsert_billing_subscription(organization_id, drop_nils(attrs))
end
defp handle_inventory_event(organization_id, _event, %{"data" => data}) do
attrs = %{
gaiia_id: data["id"],
name: data["name"],
ip_address: data["ip_address"],
assigned_account_gaiia_id: data["assigned_account_id"],
assigned_network_site_gaiia_id: data["assigned_network_site_id"]
}
Gaiia.upsert_inventory_item(organization_id, drop_nils(attrs))
end
defp upsert_account(organization_id, gaiia_id, data, overrides) do
attrs =
%{
gaiia_id: gaiia_id,
readable_id: data["readable_id"],
name: data["name"],
status: data["status"],
account_type: data["account_type"],
address: data["address"]
}
|> Map.merge(overrides)
|> drop_nils()
Gaiia.upsert_account(organization_id, attrs)
end
defp parse_decimal(nil), do: nil
defp parse_decimal(val) when is_binary(val), do: Decimal.new(val)
defp parse_decimal(val) when is_number(val), do: Decimal.from_float(val / 1)
defp drop_nils(map) do
map
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Map.new()
end
end