defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do @moduledoc """ Webhook endpoint for receiving real-time updates from Gaiia. Each organization has its own webhook secret stored in the integration credentials. The signature is verified using HMAC-SHA256 against the raw request body. Route: POST /api/v1/webhooks/gaiia/:organization_id """ use ToweropsWeb, :controller alias Towerops.Gaiia.Webhooks alias Towerops.Integrations def create(conn, %{"organization_id" => organization_id}) do with {:ok, integration} <- get_gaiia_integration(organization_id), {:ok, secret} <- get_webhook_secret(integration), {:ok, raw_body} <- get_raw_body(conn), :ok <- verify_signature(conn, raw_body, secret), {:ok, event} <- get_event(conn.body_params) do data = Map.get(conn.body_params, "data", %{}) payload = %{"entity_id" => Map.get(data, "id"), "data" => data} Webhooks.process_event(organization_id, event, payload) json(conn, %{status: "ok"}) end end defp get_gaiia_integration(organization_id) do case Integrations.get_integration(organization_id, "gaiia") do {:ok, integration} -> {:ok, integration} {:error, :not_found} -> {:error, :not_found} end end defp get_webhook_secret(integration) do case get_in(integration.credentials, ["webhook_secret"]) do nil -> {:error, :no_secret} secret -> {:ok, secret} end end defp get_raw_body(conn) do case conn.private[:raw_body] do nil -> {:error, :no_body} body -> {:ok, body} end end defp verify_signature(conn, raw_body, secret) do case get_req_header(conn, "x-gaiia-webhook-signature") do [header] -> with {:ok, timestamp, signature} <- parse_signature_header(header), :ok <- check_timestamp(timestamp) do check_signature(timestamp, raw_body, secret, signature) end _ -> {:error, :missing_signature} end end defp parse_signature_header(header) do parts = header |> String.split(",") |> Enum.map(&String.split(&1, "=", parts: 2)) |> Map.new(fn [k, v] -> {k, v} 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, :missing_signature} end rescue _ -> {:error, :missing_signature} end @max_age_seconds 300 defp check_timestamp(timestamp) do case Integer.parse(timestamp) do {ts, ""} -> now = System.system_time(:second) if abs(now - ts) <= @max_age_seconds do :ok else {:error, :invalid_signature} end _ -> {:error, :invalid_signature} end end defp check_signature(timestamp, raw_body, secret, expected) do signed_payload = timestamp <> "." <> raw_body computed = :hmac |> :crypto.mac(:sha256, secret, signed_payload) |> Base.encode16(case: :lower) if Plug.Crypto.secure_compare(computed, expected) do :ok else {:error, :invalid_signature} end end defp get_event(%{"event" => event}) when is_binary(event), do: {:ok, event} defp get_event(_), do: {:error, :missing_event} # Override action/2 to handle with clause failures def action(conn, _opts) do case apply(__MODULE__, action_name(conn), [conn, conn.params]) do {:error, :not_found} -> conn |> put_status(:not_found) |> json(%{error: "Gaiia integration not found"}) {:error, :no_secret} -> conn |> put_status(:internal_server_error) |> json(%{error: "Webhook secret not configured"}) {:error, :missing_signature} -> conn |> put_status(:unauthorized) |> json(%{error: "Missing signature header"}) {:error, :invalid_signature} -> conn |> put_status(:unauthorized) |> json(%{error: "Invalid webhook signature"}) {:error, :no_body} -> conn |> put_status(:bad_request) |> json(%{error: "Missing request body"}) {:error, :missing_event} -> conn |> put_status(:bad_request) |> json(%{error: "Missing event field"}) other -> other end end end