towerops/lib/towerops_web/controllers/api/v1/devices_controller.ex
Graham McIntire fb8ffde119
fix: prevent site assignment when sites are disabled for org
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.
2026-02-08 13:24:48 -06:00

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