Critical fixes: - Add [:safe] option to binary_to_term to prevent RCE attacks - Implement whitelist validation for String.to_atom conversions - Add input validation before String.to_existing_atom usage Changes: - MIB compiler and cache: Use safe binary deserialization - SNMP contexts: Whitelist protocol, device type, and source atoms - API controllers: Validate error message keys before atom conversion - Reduce function nesting to comply with Credo standards All 6,145 tests passing with zero Credo issues. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
238 lines
5.3 KiB
Elixir
238 lines
5.3 KiB
Elixir
defmodule ToweropsWeb.Api.V1.SitesController do
|
|
@moduledoc """
|
|
API controller for managing sites.
|
|
|
|
All endpoints require API token authentication and operations are scoped
|
|
to the organization associated with the token.
|
|
"""
|
|
use ToweropsWeb, :controller
|
|
|
|
alias Towerops.Sites
|
|
|
|
@doc """
|
|
GET /api/v1/sites
|
|
|
|
Lists all sites for the authenticated organization.
|
|
|
|
Response:
|
|
{
|
|
"sites": [
|
|
{
|
|
"id": "uuid",
|
|
"name": "Main Office",
|
|
"location": "New York, NY",
|
|
"snmp_community": "public",
|
|
"inserted_at": "2026-01-15T19:44:25Z"
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
def index(conn, _params) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
|
|
sites =
|
|
organization_id
|
|
|> Sites.list_organization_sites()
|
|
|> Enum.map(&format_site/1)
|
|
|
|
json(conn, %{sites: sites})
|
|
end
|
|
|
|
@doc """
|
|
POST /api/v1/sites
|
|
|
|
Creates a new site for the authenticated organization.
|
|
|
|
Request body:
|
|
{
|
|
"site": {
|
|
"name": "Main Office",
|
|
"location": "New York, NY",
|
|
"snmp_community": "public" # optional
|
|
}
|
|
}
|
|
|
|
Response:
|
|
{
|
|
"id": "uuid",
|
|
"name": "Main Office",
|
|
"location": "New York, NY",
|
|
"snmp_community": "public",
|
|
"inserted_at": "2026-01-15T19:44:25Z"
|
|
}
|
|
"""
|
|
def create(conn, %{"site" => site_params}) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
attrs = Map.put(site_params, "organization_id", organization_id)
|
|
|
|
case Sites.create_site(attrs) do
|
|
{:ok, site} ->
|
|
conn
|
|
|> put_status(:created)
|
|
|> json(format_site(site))
|
|
|
|
{:error, %Ecto.Changeset{} = changeset} ->
|
|
conn
|
|
|> put_status(:unprocessable_entity)
|
|
|> json(%{errors: translate_errors(changeset)})
|
|
end
|
|
end
|
|
|
|
def create(conn, _params) do
|
|
conn
|
|
|> put_status(:bad_request)
|
|
|> json(%{error: "Missing 'site' parameter"})
|
|
end
|
|
|
|
@doc """
|
|
GET /api/v1/sites/:id
|
|
|
|
Gets a single site by ID.
|
|
|
|
Response:
|
|
{
|
|
"id": "uuid",
|
|
"name": "Main Office",
|
|
"location": "New York, NY",
|
|
"snmp_community": "public",
|
|
"inserted_at": "2026-01-15T19:44:25Z"
|
|
}
|
|
"""
|
|
def show(conn, %{"id" => id}) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
site = Sites.get_site!(id)
|
|
|
|
if site.organization_id == organization_id do
|
|
json(conn, format_site(site))
|
|
else
|
|
conn
|
|
|> put_status(:forbidden)
|
|
|> json(%{error: "Access denied to this site"})
|
|
end
|
|
rescue
|
|
Ecto.NoResultsError ->
|
|
conn
|
|
|> put_status(:not_found)
|
|
|> json(%{error: "Site not found"})
|
|
end
|
|
|
|
@doc """
|
|
PATCH /api/v1/sites/:id
|
|
|
|
Updates a site.
|
|
|
|
Request body:
|
|
{
|
|
"site": {
|
|
"name": "Updated Name",
|
|
"location": "Boston, MA"
|
|
}
|
|
}
|
|
|
|
Response:
|
|
{
|
|
"id": "uuid",
|
|
"name": "Updated Name",
|
|
"location": "Boston, MA",
|
|
"snmp_community": "public",
|
|
"inserted_at": "2026-01-15T19:44:25Z"
|
|
}
|
|
"""
|
|
def update(conn, %{"id" => id, "site" => site_params}) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
site = Sites.get_site!(id)
|
|
|
|
if site.organization_id == organization_id do
|
|
case Sites.update_site(site, site_params) do
|
|
{:ok, updated_site} ->
|
|
json(conn, format_site(updated_site))
|
|
|
|
{: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 site"})
|
|
end
|
|
rescue
|
|
Ecto.NoResultsError ->
|
|
conn
|
|
|> put_status(:not_found)
|
|
|> json(%{error: "Site not found"})
|
|
end
|
|
|
|
def update(conn, _params) do
|
|
conn
|
|
|> put_status(:bad_request)
|
|
|> json(%{error: "Missing 'site' parameter"})
|
|
end
|
|
|
|
@doc """
|
|
DELETE /api/v1/sites/:id
|
|
|
|
Deletes a site.
|
|
|
|
Response:
|
|
{
|
|
"success": true
|
|
}
|
|
"""
|
|
def delete(conn, %{"id" => id}) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
site = Sites.get_site!(id)
|
|
|
|
if site.organization_id == organization_id do
|
|
case Sites.delete_site(site) do
|
|
{:ok, _site} ->
|
|
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 site"})
|
|
end
|
|
rescue
|
|
Ecto.NoResultsError ->
|
|
conn
|
|
|> put_status(:not_found)
|
|
|> json(%{error: "Site not found"})
|
|
end
|
|
|
|
# Private helpers
|
|
|
|
defp format_site(site) do
|
|
%{
|
|
id: site.id,
|
|
name: site.name,
|
|
location: site.location,
|
|
snmp_community: site.snmp_community,
|
|
inserted_at: site.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
|