2.6 KiB · text History 466a390
defmodule GitGudWeb.RepoAuth do
  @moduledoc """
  The read gate for every repository-scoped LiveView.

  ## Why this is a hook and not a check in each mount

  It used to be a check in each mount, in the sense that the router
  said so — `live_session :public_repo` carried the comment
  "Visibility is enforced inside the LiveView itself". It wasn't.
  Eighteen of the nineteen browsing LiveViews called
  `get_repository_by_path!/2` and rendered whatever came back, so a
  private repository's file tree, blobs, commits, issues, pull
  requests, wiki and CI logs were readable by anyone who typed the URL.
  The listings correctly hid those repositories, which is exactly what
  made it hard to notice.

  A rule that has to be remembered in nineteen places is a rule that is
  eventually forgotten in one. This runs before any of them mount, so a
  new repository page is gated by existing, and the only way to opt out
  is to put the route somewhere else on purpose.

  ## Why a 404 and not a 403

  A 403 confirms the repository exists. `Ecto.NoResultsError` is what
  `get_repository_by_path!/2` raises for a repository that isn't there,
  so a private repository and a nonexistent one produce byte-identical
  responses and probing tells an attacker nothing.

  ## Where the real decision lives

  `GitGud.Organizations.can?/3` — the same function the mobile API and
  the settings pages already use. This module resolves the repository
  and asks; it has no opinion of its own about who may read what.
  """

  alias GitGud.Organizations
  alias GitGud.Repositories
  alias GitGud.Repositories.Repository

  @doc """
  Halt unless the mounting user may read `owner/name`.

  Expects `:current_scope` to be assigned already, which the
  `UserAuth` hook listed before it does. An unauthenticated visitor has
  no user, and `can?/3` answers for `nil` — public and internal
  repositories are readable, private ones are not.
  """
  def on_mount(:require_read, %{"owner" => owner, "name" => name}, _session, socket) do
    user = socket.assigns[:current_scope] && socket.assigns.current_scope.user

    case Repositories.find_by_path(owner, name) do
      %Repository{} = repo ->
        if Organizations.can?(user, repo, :read),
          do: {:cont, socket},
          else: not_found()

      nil ->
        not_found()
    end
  end

  # Live sessions hold more than repository routes — the home page, an
  # org profile, a user's syms. Those carry no owner/name to check, and
  # have their own rules.
  def on_mount(:require_read, _params, _session, socket), do: {:cont, socket}

  defp not_found, do: raise(Ecto.NoResultsError, queryable: Repository)
end