- 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.
231 lines
6.5 KiB
Elixir
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
|