towerops/lib/towerops_web/controllers/api/v1/devices_controller.ex
Graham McIntire c4760ca0dc
Add API v1 endpoints with organization-scoped API tokens
Created API token system for programmatic access:
- API tokens table with organization scoping
- ApiTokens context for token management
- ApiAuth plug for Bearer token authentication
- Tokens shown once on creation, then hashed for security

Implemented RESTful API v1 endpoints:
- SitesController: CRUD operations for sites
- DevicesController: CRUD operations for devices
- All operations scoped to authenticated organization
- Proper authorization checks via site ownership

Technical details:
- Tokens prefixed with "towerops_" for easy identification
- SHA-256 hashing for token storage
- Last used timestamp tracking (async update)
- Optional token expiration support
- Standard JSON error responses (40x status codes)

Routes:
- /api/v1/sites (GET, POST, PATCH, DELETE)
- /api/v1/devices (GET, POST, PATCH, DELETE)

Authentication:
- Authorization: Bearer towerops_xxxxx header required
- Returns 401 for invalid/expired tokens
- Returns 403 for unauthorized resource access
2026-01-17 16:53:31 -06:00

299 lines
7 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
@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.
Request body:
{
"device": {
"site_id": "uuid",
"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"
"snmp_community": "public", # optional, inherits from site/org if not set
"snmp_port": 161 # optional, default 161
}
}
Response:
{
"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 create(conn, %{"device" => device_params}) do
organization_id = conn.assigns.current_organization_id
# Verify site belongs to organization if site_id is provided
case verify_site_access(device_params["site_id"], organization_id) do
:ok ->
case Devices.create_device(device_params) do
{:ok, device} ->
conn
|> put_status(:created)
|> json(format_device(device))
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: translate_errors(changeset)})
end
{: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 =
id
|> Devices.get_device!()
|> Towerops.Repo.preload(:site)
if device.site.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 =
id
|> Devices.get_device!()
|> Towerops.Repo.preload(:site)
if device.site.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 =
id
|> Devices.get_device!()
|> Towerops.Repo.preload(:site)
if device.site.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 verify_site_access(nil, _organization_id), do: {:error, "site_id is required"}
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,
monitoring_enabled: device.monitoring_enabled,
snmp_enabled: device.snmp_enabled,
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,
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,
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 ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
end)
end)
end
end