towerops/lib/towerops_web/controllers/api/v1/gaiia_webhook_controller.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

99 lines
3 KiB
Elixir

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-signature") do
[signature] ->
if Webhooks.verify_signature(raw_body, signature, secret) do
:ok
else
{:error, :invalid_signature}
end
_ ->
{:error, :missing_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