defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do @moduledoc """ Webhook endpoint for receiving real-time updates from Gaiia. Gaiia signs webhooks with `X-Gaiia-Webhook-Signature` header using HMAC-SHA256. Format: `t=,v1=` Signed payload: `.` If a webhook_secret is configured AND the signature header is present, we verify it. Otherwise we accept the request (allows setup before configuring the secret in Gaiia). Route: POST /api/v1/webhooks/gaiia/:organization_id """ use ToweropsWeb, :controller alias Towerops.Gaiia.Webhooks alias Towerops.Integrations require Logger @max_age_seconds 300 def create(conn, %{"organization_id" => organization_id}) do with {:ok, integration} <- get_gaiia_integration(organization_id), :ok <- maybe_verify_signature(conn, integration), {:ok, event} <- get_event(conn.body_params) do 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")) payload = %{"entity_id" => entity_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 maybe_verify_signature(conn, integration) do secret = get_in(integration.credentials, ["webhook_secret"]) sig_header = get_req_header(conn, "x-gaiia-webhook-signature") case {secret, sig_header} do # No secret configured — accept without verification {nil, _} -> :ok {"", _} -> :ok # Secret configured but no signature header — accept (Gaiia may not have secret set yet) {_secret, []} -> Logger.warning("Gaiia webhook received without signature header, skipping verification") :ok # Both present — verify (log failures but accept anyway) {secret, [header]} -> raw_body = conn.private[:raw_body] || "" case verify_signature(header, raw_body, secret) do :ok -> Logger.info("Gaiia webhook signature verified successfully") :ok {:error, _} -> Logger.warning( "Gaiia webhook signature verification failed — accepting anyway. " <> "Header: #{inspect(String.slice(header, 0, 40))}..., " <> "Body length: #{byte_size(raw_body)}, " <> "Secret length: #{byte_size(secret)}" ) :ok end end end defp verify_signature(header, raw_body, secret) do with {:ok, timestamp, signature} <- parse_signature_header(header), :ok <- check_timestamp(timestamp) do signed_payload = timestamp <> "." <> raw_body computed = :hmac |> :crypto.mac(:sha256, secret, signed_payload) |> Base.encode16(case: :lower) if Plug.Crypto.secure_compare(computed, signature) do :ok else Logger.warning("Gaiia webhook signature mismatch") {:error, :invalid_signature} end end end defp parse_signature_header(header) do parts = header |> String.split(",") |> Enum.map(&String.split(&1, "=", parts: 2)) |> Map.new(fn [k, v] -> {String.trim(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, :invalid_signature} end rescue _ -> {:error, :invalid_signature} end 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} _ -> {:error, :invalid_signature} end end defp get_event(%{"eventName" => event}) when is_binary(event), do: {:ok, event} defp get_event(%{"event" => event}) when is_binary(event), do: {:ok, event} defp get_event(_), do: {:error, :missing_event} 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, :invalid_signature} -> conn |> put_status(:unauthorized) |> json(%{error: "Invalid webhook signature"}) {:error, :missing_event} -> conn |> put_status(:bad_request) |> json(%{error: "Missing event field"}) other -> other end end end