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:
Graham McIntire 2026-01-17 16:53:31 -06:00
parent 5339f51dd8
commit c4760ca0dc
No known key found for this signature in database
7 changed files with 816 additions and 0 deletions

164
lib/towerops/api_tokens.ex Normal file
View 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

View 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

View 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

View 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

View 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

View file

@ -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]

View 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