lib/openagents_web/controllers/agent_controller.ex

58e6347eeb72 · 8 KB

defmodule OpenAgentsWeb.AgentController do
  use OpenAgentsWeb, :controller

  alias OpenAgents.Accounts
  alias OpenAgents.Agents
  alias OpenAgents.Agents.{Agent, AgentUserLink}
  alias OpenAgents.Repo

  def register(conn, params) do
    attributes = Map.put(params, "registration_ip", remote_ip(conn))

    case Agents.register(attributes) do
      {:ok, agent, credential} ->
        conn
        |> put_status(:created)
        |> put_resp_header("cache-control", "no-store")
        |> json(%{
          "agent" => profile(agent),
          "token" => credential,
          "warning" => "This credential is shown once. Store it securely."
        })

      {:error, {:rate_limited, window}} ->
        refusal(conn, :too_many_requests, "registration_rate_limited", %{
          "window_seconds" => window
        })

      {:error, reason} ->
        refusal(conn, :unprocessable_entity, error_code(reason))
    end
  end

  def show(conn, %{"handle" => handle}) do
    case Agents.get_by_handle(handle) do
      %Agent{} = agent -> json(conn, %{"agent" => profile(agent)})
      nil -> refusal(conn, :not_found, "agent_not_found")
    end
  end

  def current(conn, _params) do
    json(conn, %{
      "agent" => profile(conn.assigns.current_agent),
      "credential" => credential_projection(conn.assigns[:agent_token]),
      "links" => Enum.map(Agents.list_links(conn.assigns.current_agent), &link_json/1)
    })
  end

  def rotate_credential(conn, params) do
    case Agents.mint_credential(conn.assigns.current_agent, params) do
      {:ok, token, credential} ->
        conn
        |> put_status(:created)
        |> put_resp_header("cache-control", "no-store")
        |> json(%{
          "credential" => credential,
          "token" => credential_projection(token),
          "warning" => "This credential is shown once. Store it securely."
        })

      {:error, reason} ->
        refusal(conn, :unprocessable_entity, error_code(reason))
    end
  end

  def request_link(conn, %{"user_id" => user_id}) do
    with {:ok, user} <- Accounts.get_active_user(user_id),
         {:ok, link} <- Agents.request_link(conn.assigns.current_agent, user) do
      conn |> put_status(:created) |> json(%{"link" => link_json(link)})
    else
      {:error, :banned} ->
        refusal(conn, :forbidden, "user_unavailable")

      {:error, :not_found} ->
        refusal(conn, :not_found, "user_not_found")

      {:error, %Ecto.Changeset{} = changeset} ->
        conn |> put_status(:unprocessable_entity) |> json(%{"errors" => errors(changeset)})
    end
  end

  def request_link(conn, _params), do: refusal(conn, :unprocessable_entity, "user_id_required")

  def links(conn, _params) do
    json(conn, %{
      "links" => Enum.map(Agents.list_pending_links(conn.assigns.current_user), &link_json/1)
    })
  end

  def accept_link(conn, %{"id" => id}), do: review_link(conn, id, :accept)
  def reject_link(conn, %{"id" => id}), do: review_link(conn, id, :reject)
  def unlink(conn, %{"id" => id}), do: review_link(conn, id, :unlink)

  def grant_box_control(conn, %{"handle" => handle}) do
    with %Agent{} = agent <- Agents.get_by_handle(handle),
         {:ok, grant} <- Agents.grant_box_control(conn.assigns.current_user, agent) do
      json(conn, %{"grant" => grant_json(grant)})
    else
      nil -> refusal(conn, :not_found, "agent_not_found")
      {:error, reason} -> refusal(conn, :conflict, error_code(reason))
    end
  end

  def revoke_box_control(conn, %{"handle" => handle}) do
    with %Agent{} = agent <- Agents.get_by_handle(handle),
         {:ok, grant} <- Agents.revoke_box_control(conn.assigns.current_user, agent) do
      json(conn, %{"grant" => grant_json(grant)})
    else
      nil -> refusal(conn, :not_found, "agent_not_found")
      {:error, reason} -> refusal(conn, :conflict, error_code(reason))
    end
  end

  def grant_computer_control(conn, %{"handle" => handle}) do
    with %Agent{} = agent <- Agents.get_by_handle(handle),
         {:ok, grant} <- Agents.grant_computer_control(conn.assigns.current_user, agent) do
      json(conn, %{"grant" => grant_json(grant)})
    else
      nil -> refusal(conn, :not_found, "agent_not_found")
      {:error, reason} -> refusal(conn, :conflict, error_code(reason))
    end
  end

  def revoke_computer_control(conn, %{"handle" => handle}) do
    with %Agent{} = agent <- Agents.get_by_handle(handle),
         {:ok, grant} <- Agents.revoke_computer_control(conn.assigns.current_user, agent) do
      json(conn, %{"grant" => grant_json(grant)})
    else
      nil -> refusal(conn, :not_found, "agent_not_found")
      {:error, reason} -> refusal(conn, :conflict, error_code(reason))
    end
  end

  def suspend(conn, %{"handle" => handle} = params) do
    with %Agent{} = agent <- Agents.get_by_handle(handle),
         {:ok, suspended} <- Agents.suspend(agent, params["reason"] || "operator suspension") do
      json(conn, %{"agent" => profile(suspended)})
    else
      nil -> refusal(conn, :not_found, "agent_not_found")
      {:error, reason} -> refusal(conn, :unprocessable_entity, error_code(reason))
    end
  end

  def reinstate(conn, %{"handle" => handle}) do
    with %Agent{} = agent <- Agents.get_by_handle(handle),
         {:ok, reinstated} <- Agents.reinstate(agent) do
      json(conn, %{"agent" => profile(reinstated)})
    else
      nil -> refusal(conn, :not_found, "agent_not_found")
      {:error, reason} -> refusal(conn, :unprocessable_entity, error_code(reason))
    end
  end

  defp review_link(conn, id, action) do
    result =
      case action do
        :accept -> Agents.accept_link(conn.assigns.current_user, id)
        :reject -> Agents.reject_link(conn.assigns.current_user, id)
        :unlink -> Agents.unlink(conn.assigns.current_user, id)
      end

    case result do
      {:ok, link} -> json(conn, %{"link" => link_json(Repo.preload(link, [:agent, :user]))})
      {:error, :link_not_found} -> refusal(conn, :not_found, "link_not_found")
      {:error, reason} -> refusal(conn, :conflict, error_code(reason))
    end
  end

  defp profile(%Agent{} = agent) do
    %{
      "id" => agent.id,
      "handle" => agent.handle,
      "display_name" => agent.display_name,
      "description" => agent.description,
      "status" => agent.status
    }
  end

  defp credential_projection(nil), do: nil

  defp credential_projection(token) do
    %{
      "id" => token.id,
      "name" => token.name,
      "last_four" => token.last_four,
      "scopes" => token.scopes,
      "expires_at" => DateTime.to_iso8601(token.expires_at),
      "last_used_at" => token.last_used_at,
      "revoked_at" => token.revoked_at
    }
  end

  defp link_json(%AgentUserLink{} = link) do
    %{
      "id" => link.id,
      "agent_id" => link.agent_id,
      "user_id" => link.user_id,
      "status" => link.status,
      "proof_method" => link.proof_method,
      "linked_at" => link.linked_at,
      "rejected_at" => link.rejected_at
    }
  end

  defp grant_json(grant) do
    %{
      "id" => grant.id,
      "agent_id" => grant.agent_id,
      "user_id" => grant.user_id,
      "granted_by_id" => grant.granted_by_id,
      "target_kind" => grant.target_kind,
      "scope" => grant.scope,
      "granted_at" => grant.granted_at,
      "revoked_at" => grant.revoked_at
    }
  end

  defp remote_ip(conn), do: conn.remote_ip |> :inet.ntoa() |> to_string()

  defp errors(changeset), do: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)

  defp translate_error({message, options}) do
    Regex.replace(~r"%{(\w+)}", message, fn _, key ->
      to_string(Keyword.get(options, String.to_existing_atom(key), key))
    end)
  end

  defp error_code(:invalid_handle), do: "invalid_handle"
  defp error_code(:confusable_handle), do: "confusable_handle"
  defp error_code(:handle_taken), do: "handle_unavailable"
  defp error_code(:registration_ip_required), do: "registration_unavailable"
  defp error_code(:agent_suspended), do: "agent_suspended"
  defp error_code(:display_name_too_long), do: "display_name_too_long"
  defp error_code(:description_too_long), do: "description_too_long"
  defp error_code(:link_already_active), do: "link_already_active"
  defp error_code(:invalid_registration), do: "invalid_registration"
  defp error_code(:invalid_agent_credential), do: "invalid_agent_credential"
  defp error_code(:link_not_found), do: "link_not_found"
  defp error_code(:user_unavailable), do: "user_unavailable"
  defp error_code(reason) when is_atom(reason), do: Atom.to_string(reason)
  defp error_code(_reason), do: "request_refused"

  defp refusal(conn, status, code, details \\ %{}) do
    body = Map.merge(%{"error" => %{"code" => code}}, details)
    conn |> put_status(status) |> json(body)
  end
end