towerops/lib/towerops_web/controllers/api/v1/checks_controller.ex
Graham McIntire abfd44fd75 fix: 3 more high-severity bugs (H19, H20, H25)
- H19: /api/v1/mobile/auth/qr/verify no longer returns user_email. Knowing
  a QR token now only tells the caller the token is valid; the email is
  only revealed by /complete which consumes the token.
- H20: CoverageWorker max_attempts dropped from 3 to 1. The fail/2 path
  already returns :ok and writes the failure onto the coverage record,
  so the 3-attempt retry policy was never reachable and was misleading.
- H25: ChecksController.create rejects device_ids that don't belong to
  the caller's organization (via ScopedResource.fetch). Without this an
  API token could attach service checks to devices in another tenant.

Also strip bug ID references from in-code comments (per feedback that
H/M/L numbers don't survive past their bugs.md removal); commit history
keeps the audit trail.
2026-05-12 11:32:20 -05:00

231 lines
6.5 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.Devices.Device
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", "")
cond do
check_type not in @service_check_types ->
conn
|> put_status(:bad_request)
|> json(%{
error: "Invalid check type '#{check_type}'. Must be one of: #{Enum.join(@service_check_types, ", ")}"
})
not device_in_org?(check_params["device_id"], organization_id) ->
# Reject up-front so an attacker can't attach a check to a device in
# another organization by guessing its ID.
conn
|> put_status(:forbidden)
|> json(%{error: "Access denied to the referenced device"})
true ->
attrs =
check_params
|> ParamFilter.strip_sensitive()
|> Map.put("organization_id", organization_id)
create_and_respond(conn, attrs)
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
# Verify that an inbound device_id refers to a device in the caller's
# organization. Returns true when device_id is missing (the changeset
# validation will then reject it as required).
defp device_in_org?(nil, _organization_id), do: true
defp device_in_org?(device_id, organization_id) do
case ScopedResource.fetch(Device, device_id, organization_id) do
{:ok, _device} -> true
_ -> false
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