lib/openagents/api_tokens.ex

58e6347eeb72 · 8 KB

defmodule OpenAgents.ApiTokens do
  @moduledoc "Scoped, expiring first-party credentials for non-browser API clients."

  import Ecto.Query

  alias OpenAgents.Accounts
  alias OpenAgents.Accounts.User
  alias OpenAgents.Audit
  alias OpenAgents.ApiTokens.ApiToken
  alias OpenAgents.Repo

  @prefix "oa_pat_"
  # `deployments:write` speaks to the deployment control plane. It is deliberately
  # not `forge:write`, and deliberately not the operator-only
  # `deployments:promote` fleet scope: holding it lets a caller address the
  # deployment API, while repository membership and environment policy still
  # decide what it may deploy.
  # `deployments:promote` is the operator-only fleet promotion scope. It is
  # deliberately absent from every ordinary issuance path: the list below only
  # says the scope exists, and `privileged_scopes/0` says who may hold one.
  @allowed_scopes [
    "chat:account",
    "forge:write",
    "deployments:write",
    "deployments:promote",
    "box:control",
    "computer:control"
  ]
  @privileged_scopes ["deployments:promote"]
  # What signing in gets you. Both scopes are what a person who signs in from
  # the CLI came for: `forge:write` to push, `chat:account` to talk to a model
  # and to open a coder thread. Leaving chat out made a plain `openagents auth
  # login` produce a credential that could not open a thread, and the refusal
  # arrived one command later, which reads as the product being broken rather
  # than as a scope not asked for.
  @default_scopes ["chat:account", "forge:write"]
  @default_lifetime_days 30
  @maximum_lifetime_days 90
  @privileged_maximum_lifetime_days 7

  @doc "Every scope a credential may carry."
  @spec allowed_scopes() :: [String.t()]
  def allowed_scopes, do: @allowed_scopes

  @doc "The scopes a credential carries when its requester names none."
  @spec default_scopes() :: [String.t()]
  def default_scopes, do: @default_scopes

  @doc """
  Scopes that only a current operator may be issued.

  A privileged scope authorizes a fleet-wide action, so it is never mixed into
  an ordinary credential, never selectable by an ordinary account, and never
  issued for longer than `#{@privileged_maximum_lifetime_days}` days.
  """
  @spec privileged_scopes() :: [String.t()]
  def privileged_scopes, do: @privileged_scopes

  @doc "Whether this scope set contains a scope only an operator may hold."
  @spec privileged?([String.t()]) :: boolean()
  def privileged?(scopes) when is_list(scopes),
    do: Enum.any?(scopes, &(&1 in @privileged_scopes))

  def privileged?(_scopes), do: false

  @doc "The longest lifetime this scope set may be issued for, in days."
  @spec maximum_lifetime_days([String.t()]) :: pos_integer()
  def maximum_lifetime_days(scopes) do
    if privileged?(scopes), do: @privileged_maximum_lifetime_days, else: @maximum_lifetime_days
  end

  @spec create(User.t(), map()) ::
          {:ok, ApiToken.t(), String.t()} | {:error, Ecto.Changeset.t() | atom()}
  def create(%User{id: user_id} = user, attributes) when is_map(attributes) do
    with {:ok, name} <- name(attributes),
         {:ok, scopes} <- scopes(attributes),
         :ok <- authorize_scopes(user, scopes),
         {:ok, lifetime_days} <- lifetime_days(attributes, scopes) do
      id = Ecto.UUID.generate()
      secret = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
      plaintext = @prefix <> id <> "." <> secret

      Repo.transaction(fn ->
        token =
          %ApiToken{id: id, user_id: user_id, token_digest: digest(plaintext)}
          |> ApiToken.create_changeset(%{
            name: name,
            scopes: scopes,
            expires_at: DateTime.add(DateTime.utc_now(), lifetime_days, :day)
          })
          |> Repo.insert!()

        Audit.record!("api_token.created", {:user, user_id}, "api_token", token.id,
          metadata: %{"scopes" => scopes}
        )

        token
      end)
      |> case do
        {:ok, token} -> {:ok, token, plaintext}
        {:error, reason} -> {:error, reason}
      end
    end
  end

  def create(%User{}, _attributes), do: {:error, :invalid_api_token}

  @spec authenticate(String.t(), String.t()) ::
          {:ok, User.t(), ApiToken.t()} | {:error, :invalid_api_token}
  def authenticate(@prefix <> rest = plaintext, required_scope)
      when byte_size(plaintext) < 160 and required_scope in @allowed_scopes do
    result =
      Repo.transaction(fn ->
        with [id, secret] <- String.split(rest, ".", parts: 2),
             true <- byte_size(secret) in 40..64,
             {:ok, token_id} <- Ecto.UUID.cast(id),
             %ApiToken{} = token <-
               Repo.one(from(t in ApiToken, where: t.id == ^token_id, lock: "FOR UPDATE")),
             true <- Plug.Crypto.secure_compare(token.token_digest, digest(plaintext)),
             true <- usable?(token, required_scope),
             %User{status: "active"} = user <- Repo.get(User, token.user_id) do
          now = DateTime.utc_now()
          Repo.update_all(from(t in ApiToken, where: t.id == ^token.id), set: [last_used_at: now])
          {user, %{token | last_used_at: now}}
        else
          _invalid -> Repo.rollback(:invalid_api_token)
        end
      end)

    case result do
      {:ok, {user, token}} -> {:ok, user, token}
      {:error, _invalid} -> {:error, :invalid_api_token}
    end
  end

  def authenticate(_plaintext, _required_scope), do: {:error, :invalid_api_token}

  @spec list(User.t()) :: [ApiToken.t()]
  def list(%User{id: user_id}) do
    Repo.all(
      from(token in ApiToken,
        where: token.user_id == ^user_id,
        order_by: [desc: token.inserted_at]
      )
    )
  end

  @spec revoke(User.t(), String.t()) :: {:ok, ApiToken.t()} | {:error, :not_found}
  def revoke(%User{id: user_id}, id) when is_binary(id) do
    with {:ok, token_id} <- Ecto.UUID.cast(id),
         %ApiToken{user_id: ^user_id} = token <- Repo.get(ApiToken, token_id) do
      token
      |> Ecto.Changeset.change(revoked_at: DateTime.utc_now())
      |> Repo.update()
    else
      _missing -> {:error, :not_found}
    end
  end

  def revoke(%User{}, _id), do: {:error, :not_found}

  @spec metadata(User.t()) :: [map()]
  def metadata(%User{} = user) do
    Enum.map(list(user), fn token ->
      %{
        id: token.id,
        name: token.name,
        scopes: token.scopes,
        expires_at: token.expires_at,
        last_used_at: token.last_used_at,
        revoked_at: token.revoked_at,
        inserted_at: token.inserted_at
      }
    end)
  end

  defp usable?(token, required_scope) do
    is_nil(token.revoked_at) and required_scope in token.scopes and
      DateTime.compare(DateTime.utc_now(), token.expires_at) == :lt
  end

  defp name(attributes) do
    case Map.get(attributes, "name") || Map.get(attributes, :name) do
      value when is_binary(value) ->
        case String.trim(value) do
          "" -> {:error, :invalid_api_token}
          trimmed -> {:ok, String.slice(trimmed, 0, 80)}
        end

      _invalid ->
        {:error, :invalid_api_token}
    end
  end

  defp scopes(attributes) do
    requested = Map.get(attributes, "scopes") || Map.get(attributes, :scopes)

    if is_list(requested) and requested != [] and
         Enum.all?(requested, &(&1 in @allowed_scopes)) do
      {:ok, Enum.uniq(requested)}
    else
      {:error, :invalid_api_token}
    end
  end

  # A privileged credential is checked twice: once here, so a non-operator can
  # never be issued one, and again on every request, so losing operator
  # standing takes effect before the next call rather than at expiry.
  defp authorize_scopes(%User{} = user, scopes) do
    cond do
      not privileged?(scopes) -> :ok
      Accounts.admin?(user) -> :ok
      true -> {:error, :invalid_api_token}
    end
  end

  # An omitted lifetime takes the shorter of the ordinary default and the
  # scope set's ceiling. An explicit lifetime above that ceiling is refused
  # rather than silently shortened, so a caller never believes it holds a
  # longer-lived credential than it does.
  defp lifetime_days(attributes, scopes) do
    maximum = maximum_lifetime_days(scopes)

    requested =
      Map.get(attributes, "lifetime_days") || Map.get(attributes, :lifetime_days) ||
        min(@default_lifetime_days, maximum)

    case requested do
      days when is_integer(days) and days >= 1 and days <= maximum -> {:ok, days}
      days when is_binary(days) -> parse_lifetime_days(days, maximum)
      _invalid -> {:error, :invalid_api_token}
    end
  end

  defp parse_lifetime_days(value, maximum) do
    case Integer.parse(value) do
      {days, ""} when days >= 1 -> if days <= maximum, do: {:ok, days}, else: error()
      _invalid -> {:error, :invalid_api_token}
    end
  end

  defp error, do: {:error, :invalid_api_token}

  defp digest(value), do: :crypto.hash(:sha256, value)
end