Use Repo.get_by(schema, id: id, organization_id: organization_id) instead of Repo.get + pattern match so that resources from wrong orgs return :not_found instead of :forbidden, preventing org membership discovery.
216 lines
6.1 KiB
Elixir
216 lines
6.1 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, :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, :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, :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
|