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

333 lines
9.4 KiB
Elixir

defmodule ToweropsWeb.Api.V1.DevicesController do
@moduledoc """
API controller for managing devices.
All endpoints require API token authentication and operations are scoped
to the organization associated with the token.
"""
use ToweropsWeb, :controller
import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1]
alias Towerops.Devices
alias Towerops.Devices.Device
alias Towerops.Sites.Site
alias Towerops.Workers.DiscoveryWorker
alias ToweropsWeb.Api.ParamFilter
alias ToweropsWeb.ScopedResource
@doc """
GET /api/v1/devices
Lists all devices for the authenticated organization.
Query params:
- site_id: Filter by site (optional)
Response:
{
"devices": [
{
"id": "uuid",
"name": "Core Router",
"ip_address": "192.168.1.1",
"site_id": "uuid",
"monitoring_enabled": true,
"snmp_enabled": true,
"inserted_at": "2026-01-15T19:44:25Z"
}
]
}
"""
def index(conn, params) do
organization_id = conn.assigns.current_organization_id
devices =
organization_id
|> Devices.list_organization_devices(params)
|> Enum.map(&format_device/1)
json(conn, %{devices: devices})
end
@doc """
POST /api/v1/devices
Creates a new device. The device can be assigned to a site or directly to the organization.
Request body:
{
"device": {
"site_id": "uuid", # optional - if not provided, device is assigned directly to organization
"organization_id": "uuid", # optional - defaults to authenticated organization if not provided
"name": "Core Router",
"ip_address": "192.168.1.1",
"description": "Main router", # optional
"monitoring_enabled": true, # optional, default true
"snmp_enabled": true, # optional, default true
"snmp_version": "2c", # optional, default "2c" (can be "1", "2c", or "3")
"snmp_community": "public", # optional, inherits from site/org if not set
"snmp_port": 161, # optional, default 161
"device_role": "router", # optional, one of: server, switch, router, access_point, backhaul, other (default: "other")
# SNMPv3 fields (only used when snmp_version is "3"):
"snmpv3_security_level": "authPriv", # optional, one of: noAuthNoPriv, authNoPriv, authPriv
"snmpv3_username": "snmpuser", # optional
"snmpv3_auth_protocol": "SHA-256", # optional, one of: MD5, SHA, SHA-224, SHA-256, SHA-384, SHA-512
"snmpv3_auth_password": "authpass", # optional
"snmpv3_priv_protocol": "AES", # optional, one of: DES, AES, AES-192, AES-256
"snmpv3_priv_password": "privpass" # optional
}
}
Response:
{
"id": "uuid",
"name": "Core Router",
"ip_address": "192.168.1.1",
"site_id": "uuid",
"organization_id": "uuid",
"monitoring_enabled": true,
"snmp_enabled": true,
"inserted_at": "2026-01-15T19:44:25Z"
}
"""
def create(conn, %{"device" => device_params}) do
organization_id = conn.assigns.current_organization_id
current_user = conn.assigns[:current_user]
device_params =
device_params
|> ParamFilter.strip_sensitive()
|> default_organization_id(organization_id)
case verify_site_access(device_params["site_id"], organization_id) do
:ok ->
create_and_discover_device(conn, device_params, current_user)
{:error, reason} ->
conn
|> put_status(:forbidden)
|> json(%{error: reason})
end
end
def create(conn, _params) do
conn
|> put_status(:bad_request)
|> json(%{error: "Missing 'device' parameter"})
end
@doc """
GET /api/v1/devices/:id
Gets a single device by ID.
Response:
{
"id": "uuid",
"name": "Core Router",
"ip_address": "192.168.1.1",
"site_id": "uuid",
"description": "Main router",
"monitoring_enabled": true,
"check_interval_seconds": 300,
"snmp_enabled": true,
"snmp_version": "2c",
"snmp_port": 161,
"inserted_at": "2026-01-15T19:44:25Z"
}
"""
def show(conn, %{"id" => id}) do
organization_id = conn.assigns.current_organization_id
case ScopedResource.fetch(Device, id, organization_id) do
{:ok, device} ->
json(conn, format_device_details(device))
{:error, :forbidden} ->
conn
|> put_status(:forbidden)
|> json(%{error: "Access denied to this device"})
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "Device not found"})
end
end
@doc """
PATCH /api/v1/devices/:id
Updates a device.
Request body:
{
"device": {
"name": "Updated Name",
"ip_address": "192.168.1.2"
}
}
Response:
{
"id": "uuid",
"name": "Updated Name",
"ip_address": "192.168.1.2",
...
}
"""
def update(conn, %{"id" => id, "device" => device_params}) do
organization_id = conn.assigns.current_organization_id
case ScopedResource.fetch(Device, id, organization_id) do
{:ok, device} ->
case Devices.update_device(device, ParamFilter.strip_sensitive(device_params)) do
{:ok, updated_device} ->
json(conn, format_device_details(updated_device))
{: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 device"})
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "Device not found"})
end
end
def update(conn, _params) do
conn
|> put_status(:bad_request)
|> json(%{error: "Missing 'device' parameter"})
end
@doc """
DELETE /api/v1/devices/:id
Deletes a device.
Response:
{
"success": true
}
"""
def delete(conn, %{"id" => id}) do
organization_id = conn.assigns.current_organization_id
case ScopedResource.fetch(Device, id, organization_id) do
{:ok, device} ->
case Devices.delete_device(device) do
{:ok, _device} ->
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 device"})
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "Device not found"})
end
end
defp default_organization_id(device_params, organization_id) do
Map.put(device_params, "organization_id", organization_id)
end
defp create_and_discover_device(conn, device_params, current_user) do
opts = if current_user && current_user.is_superuser, do: [bypass_limits: true], else: []
case Devices.create_device(device_params, opts) do
{:ok, device} ->
_ = maybe_enqueue_discovery(device)
conn
|> put_status(:created)
|> json(format_device(device))
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: translate_errors(changeset)})
end
end
defp maybe_enqueue_discovery(device) do
if device.snmp_enabled do
DiscoveryWorker.enqueue(device.id)
end
end
defp verify_site_access(nil, _organization_id), do: :ok
defp verify_site_access(site_id, organization_id) do
case ScopedResource.fetch(Site, site_id, organization_id) do
{:ok, _site} -> :ok
{:error, :forbidden} -> {:error, "Access denied to this site"}
{:error, :not_found} -> {:error, "Site not found"}
end
end
defp format_device(device) do
%{
id: device.id,
name: device.name,
ip_address: device.ip_address,
site_id: device.site_id,
organization_id: device.organization_id,
monitoring_enabled: device.monitoring_enabled,
snmp_enabled: device.snmp_enabled,
snmp_version: device.snmp_version,
snmp_port: device.snmp_port,
device_role: device.device_role,
snmpv3_security_level: device.snmpv3_security_level,
snmpv3_username: device.snmpv3_username,
snmpv3_auth_protocol: device.snmpv3_auth_protocol,
snmpv3_auth_password_set: !is_nil(device.snmpv3_auth_password),
snmpv3_priv_protocol: device.snmpv3_priv_protocol,
snmpv3_priv_password_set: !is_nil(device.snmpv3_priv_password),
inserted_at: device.inserted_at
}
end
defp format_device_details(device) do
%{
id: device.id,
name: device.name,
ip_address: device.ip_address,
site_id: device.site_id,
organization_id: device.organization_id,
description: device.description,
monitoring_enabled: device.monitoring_enabled,
check_interval_seconds: device.check_interval_seconds,
snmp_enabled: device.snmp_enabled,
snmp_version: device.snmp_version,
snmp_port: device.snmp_port,
device_role: device.device_role,
snmpv3_security_level: device.snmpv3_security_level,
snmpv3_username: device.snmpv3_username,
snmpv3_auth_protocol: device.snmpv3_auth_protocol,
snmpv3_auth_password_set: !is_nil(device.snmpv3_auth_password),
snmpv3_priv_protocol: device.snmpv3_priv_protocol,
snmpv3_priv_password_set: !is_nil(device.snmpv3_priv_password),
inserted_at: device.inserted_at
}
end
end