towerops/lib/towerops/api_tokens.ex
Graham McIntire 0a4482f974
feat: add 5 new vendor modules and fix dialyzer issues
Vendor modules added:
- Aviat (WTM microwave radios)
- Aruba (wireless controllers and APs)
- CiscoWLC (Cisco wireless LAN controllers)
- Teltonika (RUTOS LTE routers)
- Sub10 (mmWave backhaul radios)

Dialyzer fixes:
- Fix unknown type Devices.t/0 in alert.ex and device.ex
- Fix unmatched_return warnings across multiple files
- Add :exq to PLT for Exq function detection
- Remove dead code in base.ex, dynamic.ex, vendor.ex
- Fix Device.t() type spec to allow nil name field

Tests: 1437 tests, 0 failures
Dialyzer: 0 errors
Credo: no issues
2026-01-22 09:34:50 -06:00

210 lines
4.9 KiB
Elixir

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 Ecto.Adapters.SQL.Sandbox
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 """
Lists all API tokens created by a user across all organizations.
## Examples
iex> list_user_api_tokens(user_id)
[%ApiToken{}, ...]
"""
def list_user_api_tokens(user_id) do
ApiToken
|> where([t], t.user_id == ^user_id)
|> order_by([t], desc: t.inserted_at)
|> preload(:organization)
|> 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 and user.
Also updates the last_used_at timestamp.
Returns {:ok, organization_id, user} if valid, {:error, :invalid_token} otherwise.
## Examples
iex> verify_token("valid_token")
{:ok, organization_id, %User{}}
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)
# Load user if present
token = Repo.preload(token, :user)
{:ok, token.organization_id, token.user}
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
timestamp = DateTime.truncate(DateTime.utc_now(), :second)
# In test mode, update synchronously to avoid ownership issues
# In production, use Task.start to avoid blocking the request
case Application.get_env(:towerops, :env) do
:test ->
update_token_timestamp(token, timestamp)
_ ->
spawn_async_update(token, timestamp)
end
end
defp update_token_timestamp(token, timestamp) do
token
|> Ecto.Changeset.change(last_used_at: timestamp)
|> Repo.update()
end
defp spawn_async_update(token, timestamp) do
parent = self()
_ =
Task.start(fn ->
_ = maybe_allow_sandbox(parent)
update_token_timestamp(token, timestamp)
end)
end
defp maybe_allow_sandbox(parent) do
if Application.get_env(:towerops, :sql_sandbox) do
Sandbox.allow(Repo, parent, self())
end
end
end