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 alias Towerops.Devices alias Towerops.Workers.DiscoveryWorker @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 # 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 = default_organization_id(device_params, 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 device = Devices.get_device!(id) if device.organization_id == organization_id do json(conn, format_device_details(device)) else conn |> put_status(:forbidden) |> json(%{error: "Access denied to this device"}) end rescue Ecto.NoResultsError -> conn |> put_status(:not_found) |> json(%{error: "Device not found"}) 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 device = Devices.get_device!(id) if device.organization_id == organization_id do case Devices.update_device(device, 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 else conn |> put_status(:forbidden) |> json(%{error: "Access denied to this device"}) end rescue Ecto.NoResultsError -> conn |> put_status(:not_found) |> json(%{error: "Device not found"}) 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 device = Devices.get_device!(id) if device.organization_id == organization_id do 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 else conn |> put_status(:forbidden) |> json(%{error: "Access denied to this device"}) end rescue Ecto.NoResultsError -> conn |> put_status(:not_found) |> json(%{error: "Device not found"}) end # Private helpers defp default_organization_id(device_params, organization_id) do case Map.get(device_params, "organization_id") do nil -> Map.put(device_params, "organization_id", organization_id) "" -> Map.put(device_params, "organization_id", organization_id) _provided_id -> device_params end 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 site = Towerops.Sites.get_site!(site_id) if site.organization_id == organization_id do :ok else {:error, "Access denied to this site"} end rescue Ecto.NoResultsError -> {:error, "Site not found"} 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, snmpv3_security_level: device.snmpv3_security_level, snmpv3_username: device.snmpv3_username, snmpv3_auth_protocol: device.snmpv3_auth_protocol, snmpv3_auth_password: device.snmpv3_auth_password, snmpv3_priv_protocol: device.snmpv3_priv_protocol, snmpv3_priv_password: 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, snmpv3_security_level: device.snmpv3_security_level, snmpv3_username: device.snmpv3_username, snmpv3_auth_protocol: device.snmpv3_auth_protocol, snmpv3_auth_password: device.snmpv3_auth_password, snmpv3_priv_protocol: device.snmpv3_priv_protocol, snmpv3_priv_password: device.snmpv3_priv_password, inserted_at: device.inserted_at } end defp translate_errors(changeset) do Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> Regex.replace(~r"%{(\w+)}", msg, fn _, key -> safe_translate_key(key, opts) end) end) end # Safely translate error message keys to prevent atom exhaustion defp safe_translate_key(key, opts) do # Validate key length and format before converting to atom to prevent abuse if String.length(key) <= 50 and String.match?(key, ~r/^[a-z_]+$/) do opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() else key end end end