When an organization has sites disabled (use_sites=false), the API now automatically removes any site_id from device creation requests, ensuring devices are assigned directly to the organization instead. This prevents Terraform (and other API clients) from creating devices in sites when sites functionality is disabled for the organization. Fixes issue where Terraform could bypass site restrictions via API.
366 lines
10 KiB
Elixir
366 lines
10 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
|
|
|
|
alias Towerops.Devices
|
|
alias Towerops.Organizations
|
|
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 =
|
|
device_params
|
|
|> default_organization_id(organization_id)
|
|
|> maybe_strip_site_id_if_sites_disabled(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 maybe_strip_site_id_if_sites_disabled(device_params, organization_id) do
|
|
organization = Organizations.get_organization!(organization_id)
|
|
|
|
# If sites are disabled and a site_id was provided, remove it
|
|
# This ensures devices are assigned directly to the organization
|
|
if !organization.use_sites && device_params["site_id"] do
|
|
Map.delete(device_params, "site_id")
|
|
else
|
|
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
|