3.7 KiB · text History b868907
defmodule GitGudWeb.Api.Mobile.AuditController do
  @moduledoc """
  An organization's or a repository's audit log (`GitGud.Audit`), for
  `gg audit`.

  Takes the `audit:read` scope, which no token bundle includes: it has
  to be ticked by hand. The reader is whoever may read the log on the
  web (`Audit.readable?/2`): an org's admins, a personal repository's
  owner, instance admins. The web's sudo mode has no API counterpart —
  the token, made for this, is the second factor's stand-in.

  An org or repository the caller can't see is a 404, as anywhere in
  this API; one they can see but not administer is a 403. Entries
  never carry the client address or user agent: the instance view on
  the web is the only place those show.

  Query params: `category`, `actor` (a handle), `repo` (a repository
  name, org log only), `before` (an entry id) and `limit` (at most
  100). The response's `next_before` pages on, null at the end.
  """
  use GitGudWeb, :controller

  import GitGudWeb.Api.Mobile.Helpers

  alias GitGud.Audit
  alias GitGud.Audit.Catalog
  alias GitGud.Organizations
  alias GitGudWeb.Api.Mobile.Auth
  alias GitGudWeb.Api.Mobile.JSON

  def org(conn, %{"org" => handle} = params) do
    with {:ok, user} <- Auth.require_scope(conn, "audit:read"),
         {:ok, org} <- fetch_org(conn, user, handle),
         :ok <- authorize(conn, user, {:org, org}, "@#{org.handle}"),
         {:ok, filters} <- filters(conn, params) do
      filters = Map.merge(filters, Audit.repository_filter(org, present(params["repo"])))
      page(conn, {:org, org}, filters, params)
    else
      {:error, conn} -> conn
    end
  end

  def repo(conn, %{"owner" => owner, "name" => name} = params) do
    with {:ok, user} <- Auth.require_scope(conn, "audit:read"),
         {:ok, repo} <- fetch_repo(conn, user, owner, name),
         :ok <- authorize(conn, user, {:repo, repo}, "#{owner}/#{name}"),
         {:ok, filters} <- filters(conn, params) do
      page(conn, {:repo, repo}, filters, params)
    else
      {:error, conn} -> conn
    end
  end

  defp page(conn, scope, filters, params) do
    limit = int_param(params, "limit", 50, 100)

    cursor =
      case Integer.parse(to_string(params["before"] || "")) do
        {id, ""} when id > 0 -> {:before, id}
        _ -> nil
      end

    {entries, cursors} = Audit.list(scope, filters, cursor, limit)

    json(conn, %{
      "entries" => Enum.map(entries, &JSON.audit_entry/1),
      "next_before" => cursors.older
    })
  end

  defp filters(conn, params) do
    category = present(params["category"])

    if category && category not in Catalog.categories() do
      {:error, send_error(conn, 422, "unknown category #{category}")}
    else
      {:ok,
       %{category: category}
       |> Map.merge(Audit.actor_filter(present(params["actor"]) |> strip_at()))}
    end
  end

  # Private orgs are their members' to know about.
  defp fetch_org(conn, user, handle) do
    case Organizations.get_organization_by_handle(handle) do
      nil ->
        {:error, send_error(conn, 404, "organization not found")}

      org ->
        if org.visibility != "private" or user.is_admin or Organizations.member?(org, user),
          do: {:ok, org},
          else: {:error, send_error(conn, 404, "organization not found")}
    end
  end

  defp authorize(conn, user, scope, name) do
    if Audit.readable?(user, scope),
      do: :ok,
      else: {:error, send_error(conn, 403, "only #{name}'s admins can read its audit log")}
  end

  defp present(v) when is_binary(v) do
    case String.trim(v) do
      "" -> nil
      trimmed -> trimmed
    end
  end

  defp present(_), do: nil

  defp strip_at(nil), do: nil
  defp strip_at(handle), do: String.trim_leading(handle, "@")
end