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{}} """ @spec create_api_token(map()) :: {:ok, {ApiToken.t(), String.t()}} | {:error, Ecto.Changeset.t()} 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{}, ...] """ @spec list_organization_api_tokens(binary()) :: [ApiToken.t()] 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{}, ...] """ @spec list_user_api_tokens(binary()) :: [ApiToken.t()] 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) """ @spec get_api_token!(binary()) :: ApiToken.t() 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} """ @spec verify_token(String.t()) :: {:ok, binary(), Towerops.Accounts.User.t() | nil} | {: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{}} """ @spec delete_api_token(ApiToken.t()) :: {:ok, ApiToken.t()} | {:error, Ecto.Changeset.t()} 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{}} """ @spec update_api_token(ApiToken.t(), map()) :: {:ok, ApiToken.t()} | {:error, Ecto.Changeset.t()} def update_api_token(%ApiToken{} = api_token, attrs) do api_token |> ApiToken.changeset(attrs) |> Repo.update() end # Private functions @spec generate_token() :: String.t() 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 @spec hash_token(String.t()) :: String.t() defp hash_token(token) do :sha256 |> :crypto.hash(token) |> Base.encode16(case: :lower) end @spec update_last_used(ApiToken.t()) :: :ok 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 @spec update_token_timestamp(ApiToken.t(), DateTime.t()) :: {:ok, ApiToken.t()} | {:error, Ecto.Changeset.t()} defp update_token_timestamp(token, timestamp) do token |> Ecto.Changeset.change(last_used_at: timestamp) |> Repo.update() end @spec spawn_async_update(ApiToken.t(), DateTime.t()) :: :ok 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