towerops/lib/towerops_web/controllers/api/v1/gaiia_webhook_controller.ex
Graham McIntire 3b0961dfc0 refactor(webhooks): enqueue Oban jobs immediately, ACK fast
All 4 webhook controllers now verify the signature, insert an Oban job,
and return immediately. Previously, Stripe and PagerDuty did DB queries +
writes inline; Gaiia ran upserts; Agent Release broadcast to all connected
WebSockets — all in the request path.

New workers:
- StripeWebhookWorker — delegates to WebhookProcessor.process/1
- GaiiaWebhookWorker — delegates to Webhooks.process_event/3
- PagerdutyWebhookWorker — resolve/acknowledge alerts by dedup key
- AgentReleaseWebhookWorker — broadcast mass update to agents

Also fix: log error when resolve_alert fails in agent_channel (was silently
discarded, causing "Resolving stuck device_down alert" to repeat in logs).
2026-05-10 17:18:23 -05:00

149 lines
4.8 KiB
Elixir

defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do
@moduledoc """
Webhook endpoint for receiving real-time updates from Gaiia.
Verifies the signature, enqueues an Oban job, and ACKs immediately.
All processing happens in `Towerops.Workers.GaiiaWebhookWorker`.
Route: POST /api/v1/webhooks/gaiia/:organization_id
"""
use ToweropsWeb, :controller
alias Towerops.Integrations
alias Towerops.Workers.GaiiaWebhookWorker
require Logger
@max_age_seconds 300
def create(conn, %{"organization_id" => organization_id}) do
with {:ok, integration} <- get_gaiia_integration(organization_id),
:ok <- verify_webhook(conn, integration),
{:ok, event, payload} <- extract_event(conn) do
case Oban.insert(
GaiiaWebhookWorker.new(%{
organization_id: organization_id,
event: event,
payload: payload
})
) do
{:ok, _job} ->
json(conn, %{status: "ok"})
{:error, changeset} ->
Logger.error("Failed to enqueue Gaiia webhook: #{inspect(changeset.errors)}")
conn |> put_status(500) |> json(%{error: "Failed to enqueue"})
end
else
{:error, :not_found} ->
conn |> put_status(:not_found) |> json(%{error: "Gaiia integration not found"})
{:error, :no_secret_configured} ->
conn |> put_status(:forbidden) |> json(%{error: "Webhook secret not configured"})
{:error, :invalid_signature} ->
conn |> put_status(:unauthorized) |> json(%{error: "Invalid webhook signature"})
{:error, :missing_event} ->
conn |> put_status(:bad_request) |> json(%{error: "Missing eventName field"})
{:error, reason} ->
conn |> put_status(:unauthorized) |> json(%{error: "Webhook verification failed: #{reason}"})
end
end
defp extract_event(conn) do
event = Map.get(conn.body_params, "eventName") || Map.get(conn.body_params, "event")
if is_nil(event) do
{:error, :missing_event}
else
data = Map.get(conn.body_params, "payload", Map.get(conn.body_params, "data", %{}))
entity_id = Map.get(conn.body_params, "objectId", Map.get(data, "id"))
{:ok, event, %{"entity_id" => entity_id, "data" => data}}
end
end
defp get_gaiia_integration(organization_id) do
case Integrations.get_integration(organization_id, "gaiia") do
{:ok, _integration} = ok -> ok
{:error, :not_found} -> {:error, :not_found}
end
end
defp verify_webhook(conn, integration) do
secret = get_in(integration.credentials, ["webhook_secret"])
sig_headers = Plug.Conn.get_req_header(conn, "x-gaiia-webhook-signature")
cond do
is_nil(secret) or secret == "" ->
Logger.warning("Gaiia webhook: rejected - no webhook secret configured")
{:error, :no_secret_configured}
sig_headers == [] ->
Logger.warning("Gaiia webhook: secret configured but no signature header received")
{:error, :missing_signature}
true ->
raw_body = conn.private[:raw_body] || ""
verify_gaiia_signature(hd(sig_headers), raw_body, secret)
end
end
defp verify_gaiia_signature(header, raw_body, secret) do
with {:ok, timestamp, v1_signature} <- parse_signature_header(header),
:ok <- check_timestamp(timestamp) do
signed_payload = timestamp <> "." <> raw_body
expected =
:hmac
|> :crypto.mac(:sha256, secret, signed_payload)
|> Base.encode16(case: :lower)
if Plug.Crypto.secure_compare(expected, v1_signature) do
:ok
else
Logger.warning("Gaiia webhook signature mismatch — ts=#{timestamp} body_len=#{byte_size(raw_body)}")
{:error, :invalid_signature}
end
else
{:error, :malformed} ->
Logger.warning("Gaiia webhook: malformed signature header: #{inspect(header)}")
{:error, :invalid_signature}
{:error, :expired} ->
Logger.warning("Gaiia webhook: timestamp expired (>#{@max_age_seconds}s old)")
{:error, :invalid_signature}
end
end
defp parse_signature_header(header) do
parts =
header
|> String.split(",")
|> Enum.reduce(%{}, fn element, acc ->
case String.split(element, "=", parts: 2) do
[key, value] -> Map.put(acc, String.trim(key), value)
_ -> acc
end
end)
case {Map.get(parts, "t"), Map.get(parts, "v1")} do
{t, v1} when is_binary(t) and is_binary(v1) -> {:ok, t, v1}
_ -> {:error, :malformed}
end
end
defp check_timestamp(timestamp_str) do
case Integer.parse(timestamp_str) do
{ts, ""} ->
ts_seconds = if ts > 9_999_999_999, do: div(ts, 1000), else: ts
age = abs(System.system_time(:second) - ts_seconds)
if age <= @max_age_seconds, do: :ok, else: {:error, :expired}
_ ->
{:error, :malformed}
end
end
end