towerops/lib/towerops_web/controllers/api/v1/sites_controller.ex
Graham McIntire 095c5d3236 fix: M1 — prevent cross-org resource existence probing via scoped fetch
Use Repo.get_by(schema, id: id, organization_id: organization_id) instead of
Repo.get + pattern match so that resources from wrong orgs return :not_found
instead of :forbidden, preventing org membership discovery.
2026-05-12 13:44:19 -05:00

176 lines
4.2 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
import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1]
alias Towerops.Sites
alias Towerops.Sites.Site
alias ToweropsWeb.Api.ParamFilter
alias ToweropsWeb.ScopedResource
@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.
"""
def create(conn, %{"site" => site_params}) do
organization_id = conn.assigns.current_organization_id
attrs =
site_params
|> ParamFilter.strip_sensitive()
|> Map.put("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.
"""
def show(conn, %{"id" => id}) do
organization_id = conn.assigns.current_organization_id
case ScopedResource.fetch(Site, id, organization_id) do
{:ok, site} ->
json(conn, format_site(site))
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "Site not found"})
end
end
@doc """
PATCH /api/v1/sites/:id
Updates a site.
"""
def update(conn, %{"id" => id, "site" => site_params}) do
organization_id = conn.assigns.current_organization_id
case ScopedResource.fetch(Site, id, organization_id) do
{:ok, site} ->
case Sites.update_site(site, ParamFilter.strip_sensitive(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
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "Site not found"})
end
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.
"""
def delete(conn, %{"id" => id}) do
organization_id = conn.assigns.current_organization_id
case ScopedResource.fetch(Site, id, organization_id) do
{:ok, site} ->
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
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "Site not found"})
end
end
defp format_site(site) do
%{
id: site.id,
name: site.name,
description: site.description,
location: site.location,
address: site.address,
latitude: site.latitude,
longitude: site.longitude,
display_order: site.display_order,
snmp_community: site.snmp_community,
snmp_version: site.snmp_version,
snmp_port: site.snmp_port,
snmp_transport: site.snmp_transport,
agent_token_id: site.agent_token_id,
parent_site_id: site.parent_site_id,
inserted_at: site.inserted_at
}
end
end