lib/openagents_web/controllers/reputation_controller.ex

main at 58e6347eeb72 · 7 KB

defmodule OpenAgentsWeb.ReputationController do
  @moduledoc """
  Reputation attestations for accepted outcomes, published forge first.

  Every response carries the signed claim verbatim next to its signature and
  the admitted issuer key, so a client can canonicalize the claim, check the
  Ed25519 signature, compare the verifier policy, resolve the evidence, and
  read the revocation state without trusting this API or the web interface.
  Disclosure follows the repository: a reader outside the repository sees
  `public` attestations only.

  An attestation names its subject with a bare string. The subject-claim
  actions here are how an account establishes which strings are its own — it
  asks, and an operator decides — so that an account-scoped read such as
  `GET /data/export/account` has a filter that resolves rather than guesses.
  """

  use OpenAgentsWeb, :controller

  alias OpenAgents.Accounts
  alias OpenAgents.Reputation
  alias OpenAgents.Repositories
  alias OpenAgentsWeb.ControllerHelpers

  def policy(conn, _params) do
    json(conn, %{
      "policy_id" => Reputation.policy_id(),
      "versions" => Enum.map(Reputation.policies(), &Reputation.policy_projection/1),
      "score" => nil
    })
  end

  def keys(conn, _params) do
    json(conn, %{"keys" => Enum.map(Reputation.keys(), &Reputation.key_projection/1)})
  end

  def index(conn, %{"owner" => owner, "repo" => repo, "issue_number" => issue_number}) do
    repository = Repositories.get_visible_by_path!(owner, repo, conn.assigns[:current_user])
    number = ControllerHelpers.integer_param!(issue_number)
    attestations = Reputation.list_for_issue(repository, number, tiers(conn, repository))

    json(conn, %{
      "repository" => "#{repository.owner}/#{repository.name}",
      "issue_number" => number,
      "attestations" => Enum.map(attestations, &Reputation.projection/1)
    })
  rescue
    Ecto.NoResultsError -> not_found(conn)
  end

  def show(conn, %{"owner" => owner, "repo" => repo, "id" => id}) do
    repository = Repositories.get_visible_by_path!(owner, repo, conn.assigns[:current_user])

    case Reputation.get(repository, id, tiers(conn, repository)) do
      nil -> not_found(conn)
      attestation -> json(conn, Reputation.projection(attestation))
    end
  rescue
    Ecto.NoResultsError -> not_found(conn)
  end

  def verification(conn, %{"owner" => owner, "repo" => repo, "id" => id} = params) do
    repository = Repositories.get_visible_by_path!(owner, repo, conn.assigns[:current_user])

    case Reputation.get(repository, id, tiers(conn, repository)) do
      nil -> not_found(conn)
      attestation -> json(conn, Reputation.verify(attestation, expectation(params)))
    end
  rescue
    Ecto.NoResultsError -> not_found(conn)
  end

  def subject(conn, %{"owner" => owner, "repo" => repo, "subject_id" => subject_id}) do
    repository = Repositories.get_visible_by_path!(owner, repo, conn.assigns[:current_user])

    json(conn, Reputation.subject_evidence(subject_id, repository))
  rescue
    Ecto.NoResultsError -> not_found(conn)
  end

  ## Subject claims

  @doc "Records this account's claim on an attestation subject."
  def create_subject_claim(conn, %{"subject_kind" => kind, "subject_id" => subject_id} = params)
      when is_binary(kind) and is_binary(subject_id) do
    attributes = %{
      "subject_kind" => kind,
      "subject_id" => String.trim(subject_id),
      "forum_actor_link_id" => params["forum_actor_link_id"],
      "agent_id" => params["agent_id"],
      "proof_method" => params["proof_method"] || "api_token"
    }

    case Reputation.claim_subject(conn.assigns.current_user, attributes) do
      {:ok, claim} ->
        conn
        |> put_status(:created)
        |> json(Reputation.subject_claim_projection(claim))

      {:error, %Ecto.Changeset{} = changeset} ->
        unprocessable(conn, changeset_message(changeset))

      {:error, reason} ->
        unprocessable(conn, Atom.to_string(reason))
    end
  end

  def create_subject_claim(conn, _params),
    do: unprocessable(conn, "subject_kind and subject_id are required")

  @doc "Every subject claim this account made, at every status."
  def list_subject_claims(conn, _params) do
    claims = Reputation.list_subject_claims(conn.assigns.current_user)
    json(conn, %{"claims" => Enum.map(claims, &Reputation.subject_claim_projection/1)})
  end

  @doc "Every subject claim waiting on review. Operators only."
  def pending_subject_claims(conn, _params) do
    case ensure_operator(conn) do
      :ok ->
        claims = Reputation.list_pending_subject_claims()
        json(conn, %{"claims" => Enum.map(claims, &Reputation.subject_claim_projection/1)})

      {:error, :forbidden} ->
        forbidden(conn)
    end
  end

  @doc "Approves or rejects a pending subject claim. Operators only."
  def update_subject_claim(conn, %{"id" => id, "status" => status}) do
    with :ok <- ensure_operator(conn),
         {:ok, claim} <- Reputation.fetch_subject_claim(id),
         {:ok, claim} <- review_subject_claim(claim, status) do
      json(conn, Reputation.subject_claim_projection(claim))
    else
      {:error, :forbidden} -> forbidden(conn)
      {:error, :not_found} -> not_found(conn)
      {:error, :invalid_status} -> unprocessable(conn, "status must be linked or rejected")
      {:error, :not_pending} -> conflict(conn, "claim_not_pending")
      {:error, %Ecto.Changeset{} = changeset} -> unprocessable(conn, changeset_message(changeset))
    end
  end

  def update_subject_claim(conn, _params),
    do: unprocessable(conn, "status must be linked or rejected")

  defp ensure_operator(conn) do
    if Accounts.admin?(conn.assigns[:current_user]), do: :ok, else: {:error, :forbidden}
  end

  defp review_subject_claim(claim, "linked"), do: Reputation.approve_subject_claim(claim)
  defp review_subject_claim(claim, "rejected"), do: Reputation.reject_subject_claim(claim)
  defp review_subject_claim(_claim, _status), do: {:error, :invalid_status}

  defp changeset_message(changeset) do
    changeset
    |> Ecto.Changeset.traverse_errors(fn {message, _opts} -> message end)
    |> Enum.map_join("; ", fn {field, messages} ->
      "#{field} #{Enum.join(messages, ", ")}"
    end)
  end

  defp unprocessable(conn, message),
    do: conn |> put_status(:unprocessable_entity) |> json(%{"message" => message})

  defp conflict(conn, message),
    do: conn |> put_status(:conflict) |> json(%{"message" => message})

  defp forbidden(conn), do: conn |> put_status(:forbidden) |> json(%{"message" => "Forbidden"})

  # What the caller believes it is looking at. A mismatch is reported rather
  # than corrected, which is how a replayed attestation fails for a client
  # that names the issue, revision, subject, or verifier it expects.
  defp expectation(params) do
    %{
      subject_id: params["subject_id"],
      revision: params["revision"],
      event_type: params["event_type"],
      policy_id: params["policy_id"]
    }
    |> Enum.reject(fn {_field, value} -> is_nil(value) end)
    |> Map.new()
  end

  defp tiers(conn, repository) do
    if Repositories.member?(repository, conn.assigns[:current_user]) do
      ~w(public repository private)
    else
      ~w(public)
    end
  end

  defp not_found(conn), do: conn |> put_status(:not_found) |> json(%{message: "Not Found"})
end