defmodule SyntropyWeb.Api.TokenController do use SyntropyWeb, :controller alias Syntropy.Persistence alias Syntropy.Persistence.ApiToken alias SyntropyWeb.ApiAuth @token_bytes 24 @doc "GET /api/tokens — list scoped tokens (admin only; digests, never plaintext)." @spec index(Plug.Conn.t(), map()) :: Plug.Conn.t() def index(conn, _params) do case require_persistence() do :ok -> json(conn, %{data: Enum.map(Persistence.list_api_tokens(), &token_json/1)}) {:error, response} -> send_error(conn, response) end end @doc "POST /api/tokens — create a scoped token. The plaintext appears in this response only." @spec create(Plug.Conn.t(), map()) :: Plug.Conn.t() def create(conn, params) do with :ok <- require_persistence(), {:ok, label} <- parse_label(params), {:ok, scope} <- parse_scope(params) do plaintext = "syg_" <> Base.encode16(:crypto.strong_rand_bytes(@token_bytes), case: :lower) token_id = "token-" <> Base.encode16(:crypto.strong_rand_bytes(4), case: :lower) attrs = %{ token_id: token_id, label: label, token_hash: ApiAuth.hash_token(plaintext), scope: scope, active: true } case Persistence.insert_api_token(attrs) do :ok -> record = Enum.find(Persistence.list_api_tokens(), &(&1.token_id == token_id)) conn |> put_status(:created) |> json(%{data: %{token: plaintext, record: token_json(record)}}) {:error, _reason} -> send_error( conn, {:internal_server_error, "token_store_failed", "The token could not be stored."} ) end else {:error, response} -> send_error(conn, response) end end @doc "DELETE /api/tokens/:id — deactivate a scoped token." @spec delete(Plug.Conn.t(), map()) :: Plug.Conn.t() def delete(conn, %{"id" => token_id}) do with :ok <- require_persistence(), :ok <- Persistence.deactivate_api_token(token_id) do json(conn, %{data: %{id: token_id, active: false}}) else {:error, :not_found} -> send_error(conn, {:not_found, "not_found", "No token with this id exists."}) {:error, {_status, _code, _message} = response} -> send_error(conn, response) {:error, _reason} -> send_error( conn, {:internal_server_error, "token_store_failed", "The token could not be updated."} ) end end defp require_persistence do if Persistence.enabled?() do :ok else {:error, {:service_unavailable, "persistence_disabled", "Scoped API tokens require persistence (SYNTROPY_ENABLE_PERSISTENCE)."}} end end defp parse_label(%{"label" => label}) when is_binary(label) do case String.trim(label) do "" -> {:error, {:unprocessable_entity, "invalid_request", "A token label is required."}} trimmed -> {:ok, trimmed} end end defp parse_label(_params), do: {:error, {:unprocessable_entity, "invalid_request", "A token label is required."}} defp parse_scope(%{"scope" => scope}) when is_binary(scope) do if scope in ApiToken.scopes() do {:ok, scope} else {:error, {:unprocessable_entity, "invalid_request", "Scope must be read, write, or admin."}} end end defp parse_scope(_params), do: {:ok, "read"} defp token_json(nil), do: nil defp token_json(token) do %{ id: token.token_id, label: token.label, scope: token.scope, active: token.active, hash_preview: "sha256:" <> String.slice(token.token_hash, 0, 12), inserted_at: token.inserted_at } end defp send_error(conn, {status, code, message}) do conn |> put_status(status) |> json(%{error: %{code: code, message: message}}) end end