towerops/lib/towerops_web/controllers/api/v1/checks_controller.ex
Graham McIntire b252cb81b9 fix: 5 more high-severity bugs (H6, H8, H17, H18)
- H6: batch interface stats query in get_site_capacity_summary, replacing
  per-interface SELECT with a single grouped query
- H8: explicit expires_at check in MobileAuth and GraphQLAuth plugs as
  defense in depth — the query already filters expired rows, but a future
  refactor dropping the filter can't quietly re-enable revoked sessions
- H17: Reports LiveView (toggle/delete/run_now) now uses
  Reports.get_organization_report/2 to scope by organization_id, fixing
  IDOR where a user could manipulate other tenants' reports by ID
- H18: ToweropsWeb.Api.ParamFilter strips identity fields (id,
  organization_id, user_id, created_by_id, inserted_at, updated_at) from
  user-supplied API params; applied to devices, sites, checks, and
  escalation_policies create/update paths to prevent mass-assignment of
  tenant ownership fields if a changeset cast list ever drifts
2026-05-12 11:04:31 -05:00

207 lines
5.6 KiB
Elixir

defmodule ToweropsWeb.Api.V1.ChecksController do
@moduledoc """
API controller for managing service checks (HTTP, TCP, DNS, ping).
All endpoints require API token authentication and operations are scoped
to the organization associated with the token. Only service check types
(http, tcp, dns, ping) are exposed — SNMP checks are managed internally.
"""
use ToweropsWeb, :controller
import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1]
alias Towerops.Monitoring
alias Towerops.Monitoring.Check
alias ToweropsWeb.Api.ParamFilter
alias ToweropsWeb.ScopedResource
@service_check_types ~w(http tcp dns ping)
@doc """
GET /api/v1/checks
Lists service checks for the authenticated organization.
Optional query params: `check_type`, `device_id`.
"""
def index(conn, params) do
organization_id = conn.assigns.current_organization_id
opts =
[]
|> maybe_add_opt(:device_id, params["device_id"])
|> maybe_add_opt(:check_type, params["check_type"])
checks =
organization_id
|> Monitoring.list_checks(opts)
|> Enum.filter(&(&1.check_type in @service_check_types))
|> Enum.map(&format_check/1)
json(conn, %{checks: checks})
end
@doc """
POST /api/v1/checks
Creates a new service check for the authenticated organization.
"""
def create(conn, %{"check" => check_params}) do
organization_id = conn.assigns.current_organization_id
check_type = Map.get(check_params, "check_type", "")
if check_type in @service_check_types do
attrs =
check_params
|> ParamFilter.strip_sensitive()
|> Map.put("organization_id", organization_id)
create_and_respond(conn, attrs)
else
conn
|> put_status(:bad_request)
|> json(%{error: "Invalid check type '#{check_type}'. Must be one of: #{Enum.join(@service_check_types, ", ")}"})
end
end
def create(conn, _params) do
conn
|> put_status(:bad_request)
|> json(%{error: "Missing 'check' parameter"})
end
defp create_and_respond(conn, attrs) do
case Monitoring.create_check(attrs) do
{:ok, check} ->
_ = if check.enabled, do: Monitoring.schedule_check(check)
conn
|> put_status(:created)
|> json(format_check(check))
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: translate_errors(changeset)})
end
end
@doc """
GET /api/v1/checks/:id
Gets a single check by ID.
"""
def show(conn, %{"id" => id}) do
organization_id = conn.assigns.current_organization_id
case ScopedResource.fetch(Check, id, organization_id) do
{:ok, check} ->
json(conn, format_check(check))
{:error, :forbidden} ->
conn
|> put_status(:forbidden)
|> json(%{error: "Access denied to this check"})
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "Check not found"})
end
end
@doc """
PATCH /api/v1/checks/:id
Updates a check.
"""
def update(conn, %{"id" => id, "check" => check_params}) do
organization_id = conn.assigns.current_organization_id
case ScopedResource.fetch(Check, id, organization_id) do
{:ok, check} ->
case Monitoring.update_check(check, ParamFilter.strip_sensitive(check_params)) do
{:ok, updated_check} ->
json(conn, format_check(updated_check))
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: translate_errors(changeset)})
end
{:error, :forbidden} ->
conn
|> put_status(:forbidden)
|> json(%{error: "Access denied to this check"})
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "Check not found"})
end
end
def update(conn, _params) do
conn
|> put_status(:bad_request)
|> json(%{error: "Missing 'check' parameter"})
end
@doc """
DELETE /api/v1/checks/:id
Deletes a check and cancels any scheduled jobs.
"""
def delete(conn, %{"id" => id}) do
organization_id = conn.assigns.current_organization_id
case ScopedResource.fetch(Check, id, organization_id) do
{:ok, check} ->
case Monitoring.delete_check(check) do
{:ok, _check} ->
json(conn, %{success: true})
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: translate_errors(changeset)})
end
{:error, :forbidden} ->
conn
|> put_status(:forbidden)
|> json(%{error: "Access denied to this check"})
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "Check not found"})
end
end
defp format_check(check) do
%{
id: check.id,
name: check.name,
check_type: check.check_type,
description: check.description,
enabled: check.enabled,
device_id: check.device_id,
agent_token_id: check.agent_token_id,
interval_seconds: check.interval_seconds,
timeout_ms: check.timeout_ms,
retry_interval_seconds: check.retry_interval_seconds,
max_check_attempts: check.max_check_attempts,
config: check.config,
current_state: check.current_state,
current_state_type: check.current_state_type,
last_check_at: check.last_check_at,
inserted_at: check.inserted_at
}
end
defp maybe_add_opt(opts, _key, nil), do: opts
defp maybe_add_opt(opts, _key, ""), do: opts
defp maybe_add_opt(opts, key, value), do: Keyword.put(opts, key, value)
end