From c4760ca0dc626722c51ecc0aeac822bc59e57206 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 17 Jan 2026 16:53:31 -0600 Subject: [PATCH] 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 --- lib/towerops/api_tokens.ex | 164 ++++++++++ lib/towerops/api_tokens/api_token.ex | 38 +++ .../controllers/api/v1/devices_controller.ex | 299 ++++++++++++++++++ .../controllers/api/v1/sites_controller.ex | 228 +++++++++++++ lib/towerops_web/plugs/api_auth.ex | 52 +++ lib/towerops_web/router.ex | 13 + .../20260117224818_create_api_tokens.exs | 22 ++ 7 files changed, 816 insertions(+) create mode 100644 lib/towerops/api_tokens.ex create mode 100644 lib/towerops/api_tokens/api_token.ex create mode 100644 lib/towerops_web/controllers/api/v1/devices_controller.ex create mode 100644 lib/towerops_web/controllers/api/v1/sites_controller.ex create mode 100644 lib/towerops_web/plugs/api_auth.ex create mode 100644 priv/repo/migrations/20260117224818_create_api_tokens.exs diff --git a/lib/towerops/api_tokens.ex b/lib/towerops/api_tokens.ex new file mode 100644 index 00000000..410b40d0 --- /dev/null +++ b/lib/towerops/api_tokens.ex @@ -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 diff --git a/lib/towerops/api_tokens/api_token.ex b/lib/towerops/api_tokens/api_token.ex new file mode 100644 index 00000000..d740a01c --- /dev/null +++ b/lib/towerops/api_tokens/api_token.ex @@ -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 diff --git a/lib/towerops_web/controllers/api/v1/devices_controller.ex b/lib/towerops_web/controllers/api/v1/devices_controller.ex new file mode 100644 index 00000000..886ebd8f --- /dev/null +++ b/lib/towerops_web/controllers/api/v1/devices_controller.ex @@ -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 diff --git a/lib/towerops_web/controllers/api/v1/sites_controller.ex b/lib/towerops_web/controllers/api/v1/sites_controller.ex new file mode 100644 index 00000000..45c332d6 --- /dev/null +++ b/lib/towerops_web/controllers/api/v1/sites_controller.ex @@ -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 diff --git a/lib/towerops_web/plugs/api_auth.ex b/lib/towerops_web/plugs/api_auth.ex new file mode 100644 index 00000000..99e3a5f5 --- /dev/null +++ b/lib/towerops_web/plugs/api_auth.ex @@ -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 diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 84992214..7cdaffc1 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -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] diff --git a/priv/repo/migrations/20260117224818_create_api_tokens.exs b/priv/repo/migrations/20260117224818_create_api_tokens.exs new file mode 100644 index 00000000..06dc0ede --- /dev/null +++ b/priv/repo/migrations/20260117224818_create_api_tokens.exs @@ -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