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
This commit is contained in:
parent
5339f51dd8
commit
c4760ca0dc
7 changed files with 816 additions and 0 deletions
164
lib/towerops/api_tokens.ex
Normal file
164
lib/towerops/api_tokens.ex
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
defmodule Towerops.ApiTokens do
|
||||
@moduledoc """
|
||||
Context for managing API tokens.
|
||||
|
||||
API tokens provide programmatic access to the Towerops API and are scoped
|
||||
to a specific organization.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
|
||||
alias Towerops.ApiTokens.ApiToken
|
||||
alias Towerops.Repo
|
||||
|
||||
@doc """
|
||||
Creates an API token for an organization.
|
||||
|
||||
Returns {:ok, {api_token, raw_token}} where raw_token is the plain text
|
||||
token that should be shown to the user once.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_api_token(%{organization_id: org_id, name: "Production API"})
|
||||
{:ok, {%ApiToken{}, "towerops_..."}}
|
||||
|
||||
iex> create_api_token(%{name: ""})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
"""
|
||||
def create_api_token(attrs \\ %{}) do
|
||||
# Generate a random token
|
||||
raw_token = generate_token()
|
||||
token_hash = hash_token(raw_token)
|
||||
|
||||
attrs = Map.put(attrs, :token_hash, token_hash)
|
||||
|
||||
case %ApiToken{}
|
||||
|> ApiToken.changeset(attrs)
|
||||
|> Repo.insert() do
|
||||
{:ok, api_token} ->
|
||||
{:ok, {api_token, raw_token}}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists all API tokens for an organization.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_organization_api_tokens(org_id)
|
||||
[%ApiToken{}, ...]
|
||||
"""
|
||||
def list_organization_api_tokens(organization_id) do
|
||||
ApiToken
|
||||
|> where([t], t.organization_id == ^organization_id)
|
||||
|> order_by([t], desc: t.inserted_at)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single API token by ID.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_api_token!(id)
|
||||
%ApiToken{}
|
||||
|
||||
iex> get_api_token!("nonexistent")
|
||||
** (Ecto.NoResultsError)
|
||||
"""
|
||||
def get_api_token!(id), do: Repo.get!(ApiToken, id)
|
||||
|
||||
@doc """
|
||||
Verifies an API token and returns the associated organization ID.
|
||||
|
||||
Also updates the last_used_at timestamp.
|
||||
|
||||
Returns {:ok, organization_id} if valid, {:error, :invalid_token} otherwise.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> verify_token("valid_token")
|
||||
{:ok, organization_id}
|
||||
|
||||
iex> verify_token("invalid")
|
||||
{:error, :invalid_token}
|
||||
"""
|
||||
def verify_token(raw_token) do
|
||||
token_hash = hash_token(raw_token)
|
||||
|
||||
case Repo.get_by(ApiToken, token_hash: token_hash) do
|
||||
nil ->
|
||||
{:error, :invalid_token}
|
||||
|
||||
token ->
|
||||
# Check if token is expired
|
||||
if token.expires_at && DateTime.before?(token.expires_at, DateTime.utc_now()) do
|
||||
{:error, :invalid_token}
|
||||
else
|
||||
# Update last_used_at
|
||||
update_last_used(token)
|
||||
{:ok, token.organization_id}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes an API token.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_api_token(api_token)
|
||||
{:ok, %ApiToken{}}
|
||||
|
||||
iex> delete_api_token(api_token)
|
||||
{:error, %Ecto.Changeset{}}
|
||||
"""
|
||||
def delete_api_token(%ApiToken{} = api_token) do
|
||||
Repo.delete(api_token)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates an API token (name, expiration).
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_api_token(api_token, %{name: "New Name"})
|
||||
{:ok, %ApiToken{}}
|
||||
|
||||
iex> update_api_token(api_token, %{name: nil})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
"""
|
||||
def update_api_token(%ApiToken{} = api_token, attrs) do
|
||||
api_token
|
||||
|> ApiToken.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
# Private functions
|
||||
|
||||
defp generate_token do
|
||||
# Generate a 32-byte random token and encode it as base64
|
||||
# Prefix with "towerops_" for easy identification
|
||||
random_bytes = :crypto.strong_rand_bytes(32)
|
||||
"towerops_" <> Base.url_encode64(random_bytes, padding: false)
|
||||
end
|
||||
|
||||
defp hash_token(token) do
|
||||
:sha256
|
||||
|> :crypto.hash(token)
|
||||
|> Base.encode16(case: :lower)
|
||||
end
|
||||
|
||||
defp update_last_used(token) do
|
||||
# Use Task.start to avoid blocking the request
|
||||
# We don't care if this fails
|
||||
Task.start(fn ->
|
||||
token
|
||||
|> Ecto.Changeset.change(last_used_at: DateTime.utc_now())
|
||||
|> Repo.update()
|
||||
end)
|
||||
end
|
||||
end
|
||||
38
lib/towerops/api_tokens/api_token.ex
Normal file
38
lib/towerops/api_tokens/api_token.ex
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
defmodule Towerops.ApiTokens.ApiToken do
|
||||
@moduledoc """
|
||||
Schema for API tokens scoped to organizations.
|
||||
|
||||
API tokens allow programmatic access to the Towerops API for managing
|
||||
sites, devices, and other resources within an organization.
|
||||
|
||||
The actual token value is only shown once when created and is then
|
||||
hashed for secure storage.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "api_tokens" do
|
||||
field :name, :string
|
||||
field :token_hash, :string
|
||||
field :last_used_at, :utc_datetime
|
||||
field :expires_at, :utc_datetime
|
||||
|
||||
belongs_to :organization, Towerops.Organizations.Organization
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(api_token, attrs) do
|
||||
api_token
|
||||
|> cast(attrs, [:organization_id, :name, :token_hash, :expires_at, :last_used_at])
|
||||
|> validate_required([:organization_id, :name, :token_hash])
|
||||
|> validate_length(:name, min: 1, max: 255)
|
||||
|> unique_constraint(:token_hash)
|
||||
|> foreign_key_constraint(:organization_id)
|
||||
end
|
||||
end
|
||||
299
lib/towerops_web/controllers/api/v1/devices_controller.ex
Normal file
299
lib/towerops_web/controllers/api/v1/devices_controller.ex
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
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
|
||||
228
lib/towerops_web/controllers/api/v1/sites_controller.ex
Normal file
228
lib/towerops_web/controllers/api/v1/sites_controller.ex
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
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 ->
|
||||
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
|
||||
end)
|
||||
end)
|
||||
end
|
||||
end
|
||||
52
lib/towerops_web/plugs/api_auth.ex
Normal file
52
lib/towerops_web/plugs/api_auth.ex
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
defmodule ToweropsWeb.Plugs.ApiAuth do
|
||||
@moduledoc """
|
||||
Plug for authenticating API requests using Bearer tokens.
|
||||
|
||||
Expects the Authorization header to contain a Bearer token:
|
||||
Authorization: Bearer towerops_xxxxx
|
||||
|
||||
Sets the following assigns on successful authentication:
|
||||
- :current_organization_id - The organization ID the token is scoped to
|
||||
- :api_authenticated - true
|
||||
|
||||
Returns 401 Unauthorized if:
|
||||
- No Authorization header is present
|
||||
- Token is invalid or expired
|
||||
- Token format is incorrect
|
||||
"""
|
||||
|
||||
import Phoenix.Controller, only: [json: 2]
|
||||
import Plug.Conn
|
||||
|
||||
alias Towerops.ApiTokens
|
||||
|
||||
def init(opts), do: opts
|
||||
|
||||
def call(conn, _opts) do
|
||||
case get_req_header(conn, "authorization") do
|
||||
["Bearer " <> token] ->
|
||||
authenticate_with_token(conn, token)
|
||||
|
||||
_ ->
|
||||
conn
|
||||
|> put_status(:unauthorized)
|
||||
|> json(%{error: "Missing or invalid Authorization header"})
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
|
||||
defp authenticate_with_token(conn, token) do
|
||||
case ApiTokens.verify_token(token) do
|
||||
{:ok, organization_id} ->
|
||||
conn
|
||||
|> assign(:current_organization_id, organization_id)
|
||||
|> assign(:api_authenticated, true)
|
||||
|
||||
{:error, :invalid_token} ->
|
||||
conn
|
||||
|> put_status(:unauthorized)
|
||||
|> json(%{error: "Invalid or expired API token"})
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -25,6 +25,11 @@ defmodule ToweropsWeb.Router do
|
|||
plug ToweropsWeb.Plugs.MobileAuth
|
||||
end
|
||||
|
||||
pipeline :api_v1 do
|
||||
plug :accepts, ["json"]
|
||||
plug ToweropsWeb.Plugs.ApiAuth
|
||||
end
|
||||
|
||||
# Health check endpoint for Kubernetes probes (no authentication required)
|
||||
scope "/", ToweropsWeb do
|
||||
get "/health", HealthController, :index
|
||||
|
|
@ -63,6 +68,14 @@ defmodule ToweropsWeb.Router do
|
|||
get "/devices/:id", MobileController, :get_equipment
|
||||
end
|
||||
|
||||
# API v1 routes (requires API token authentication)
|
||||
scope "/api/v1", ToweropsWeb.Api.V1 do
|
||||
pipe_through :api_v1
|
||||
|
||||
resources "/sites", SitesController, except: [:new, :edit]
|
||||
resources "/devices", DevicesController, except: [:new, :edit]
|
||||
end
|
||||
|
||||
# WebAuthn API routes
|
||||
scope "/api/webauthn", ToweropsWeb do
|
||||
pipe_through [:browser]
|
||||
|
|
|
|||
22
priv/repo/migrations/20260117224818_create_api_tokens.exs
Normal file
22
priv/repo/migrations/20260117224818_create_api_tokens.exs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
defmodule Towerops.Repo.Migrations.CreateApiTokens do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:api_tokens, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
|
||||
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :name, :string, null: false
|
||||
add :token_hash, :string, null: false
|
||||
add :last_used_at, :utc_datetime
|
||||
add :expires_at, :utc_datetime
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create index(:api_tokens, [:organization_id])
|
||||
create unique_index(:api_tokens, [:token_hash])
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue