4.8 KiB · text History 95e9713
defmodule GitGud.Packages.Access do
  @moduledoc """
  Who may do what to a repository's packages and container images.

  One decision for both registries. The OCI token endpoint
  (`GitGudWeb.RegistryController`) and the package registries
  (`GitGud.Packages.Auth`) used to answer this separately, and they
  disagreed: the container registry let anonymous callers pull
  *internal* images and ignored org admins and teams, while the package
  registry ignored what a personal token was scoped to. Both now ask
  here, and a user's access goes through `GitGud.Organizations.can?/3` —
  the same rule the web UI applies.

  ## Principals

    * `{:user, user, scopes}` — a forge account. `scopes` is `:all` for a
      password (or a signed-in browser session) and the token's scopes for
      a personal access token (`ggp_…`), which limit what the account's
      own permission would otherwise allow.
    * `{:token, repository_id, scopes}` — a repo-scoped registry token
      (`ggrt_…`), `pull` and/or `push`.
    * `{:job, repository_id}` — a running CI job's `${{ github.token }}`.
    * `:anonymous`

  ## Actions

  `:read` (pull, download), `:write` (push, publish) and `:delete`
  (removing image manifests, which only repository admins may do).

  ## Personal token scopes

  Reading wants `packages:read`, `packages:write`, `repo` or `repo:read`;
  writing wants `packages:write` or `repo`; deleting wants `repo`. `repo`
  predates the finer scopes and keeps meaning everything.
  """

  alias GitGud.Accounts.User
  alias GitGud.Organizations
  alias GitGud.Repositories
  alias GitGud.Repositories.Repository
  alias GitGud.Workflows.JobTokenGrants

  @type principal ::
          {:user, User.t(), :all | [String.t()]}
          | {:token, integer(), [String.t()]}
          | {:job, integer()}
          | :anonymous

  @type action :: :read | :write | :delete

  @read_scopes ~w(packages:read packages:write repo repo:read)
  @write_scopes ~w(packages:write repo)
  @delete_scopes ~w(repo)

  @doc "May `principal` perform `action` on `repo`'s packages and images?"
  @spec allowed?(principal(), Repository.t(), action()) :: boolean()
  def allowed?({:token, repo_id, scopes}, %Repository{id: repo_id}, action) do
    case action do
      :read -> "pull" in scopes
      :write -> "push" in scopes
      :delete -> false
    end
  end

  # A registry token minted for a different repository is worth no more
  # than an anonymous request.
  def allowed?({:token, _other, _scopes}, %Repository{} = repo, action),
    do: allowed?(:anonymous, repo, action)

  # A job token carries the forge's own judgement that the job belongs to
  # its repository, so it reads and writes that repository's packages with
  # no stored credential. Elsewhere it reads only what the other
  # repository has granted it (or what anyone may read).
  def allowed?({:job, repo_id}, %Repository{id: repo_id}, action),
    do: action in [:read, :write]

  def allowed?({:job, source_id}, %Repository{} = repo, :read),
    do: JobTokenGrants.allows?(repo, source_id, :read) or allowed?(:anonymous, repo, :read)

  def allowed?({:job, _other}, %Repository{}, _action), do: false

  def allowed?({:user, %User{} = user, scopes}, %Repository{} = repo, action) do
    scope_permits?(scopes, action) and can?(user, repo, permission(action))
  end

  def allowed?(:anonymous, %Repository{} = repo, :read), do: can?(nil, repo, :read)

  def allowed?(:anonymous, %Repository{}, _action), do: false

  # `Organizations.can?/3` answers nil for "no permission at all".
  defp can?(user, repo, permission), do: Organizations.can?(user, repo, permission) == true

  defp permission(:read), do: :read
  defp permission(:write), do: :write
  defp permission(:delete), do: :admin

  defp scope_permits?(:all, _action), do: true
  defp scope_permits?(scopes, :read), do: Enum.any?(scopes, &(&1 in @read_scopes))
  defp scope_permits?(scopes, :write), do: Enum.any?(scopes, &(&1 in @write_scopes))
  defp scope_permits?(scopes, :delete), do: Enum.any?(scopes, &(&1 in @delete_scopes))

  @doc """
  The repository a registry image name belongs to.

  An image is named for its repository, `owner/name`, or for something
  under it — `owner/name/server`, `owner/name/web/assets` — so a
  repository can publish several images, as on GitLab. Whatever applies
  to the repository applies to every image beneath it.
  """
  @spec repository_for_image(String.t()) :: {:ok, Repository.t()} | :error
  def repository_for_image(image) when is_binary(image) do
    case String.split(image, "/") do
      [owner, name | rest] when owner != "" and name != "" ->
        if Enum.all?(rest, &(&1 != "")) do
          try do
            {:ok, Repositories.get_repository_by_path!(owner, name)}
          rescue
            Ecto.NoResultsError -> :error
          end
        else
          :error
        end

      _ ->
        :error
    end
  end
end