3.7 KiB · text History b868907
defmodule GitGud.Workflows.JobTokenGrants do
  @moduledoc """
  The job-token allowlist: which other repositories' CI jobs a repository
  lets read its images and packages, or dispatch its workflows.

  See `GitGud.Workflows.JobTokenGrant`. Checked by
  `GitGud.Packages.Access` (reads) and the workflow dispatch API.
  """

  import Ecto.Query

  alias GitGud.Organizations
  alias GitGud.Organizations.Organization
  alias GitGud.Repo
  alias GitGud.Repositories
  alias GitGud.Repositories.Repository
  alias GitGud.Workflows.JobTokenGrant

  @doc """
  Does `repo` let jobs of the repository `source_repo_id` do `action`
  (`:read` or `:dispatch`)? A repository always may, to itself.
  """
  @spec allows?(Repository.t(), integer(), :read | :dispatch) :: boolean()
  def allows?(%Repository{id: id}, id, _action), do: true

  def allows?(%Repository{id: repo_id}, source_repo_id, action)
      when is_integer(source_repo_id) and action in [:read, :dispatch] do
    source_org_id =
      Repo.one(from r in Repository, where: r.id == ^source_repo_id, select: r.organization_id)

    query =
      from g in JobTokenGrant,
        where: g.repository_id == ^repo_id,
        where: field(g, ^action) == true

    query =
      if source_org_id do
        where(
          query,
          [g],
          g.source_repository_id == ^source_repo_id or g.source_organization_id == ^source_org_id
        )
      else
        where(query, [g], g.source_repository_id == ^source_repo_id)
      end

    Repo.exists?(query)
  end

  def allows?(_repo, _source, _action), do: false

  @doc "The grants on `repo`, sources preloaded, oldest first."
  def list(%Repository{id: repo_id}) do
    Repo.all(
      from g in JobTokenGrant,
        where: g.repository_id == ^repo_id,
        order_by: [asc: g.inserted_at, asc: g.id],
        preload: [source_repository: [:owner, :organization], source_organization: []]
    )
  end

  @doc """
  Grant `source` — `"owner/name"` for one repository, or an organization's
  handle for all of its repositories — on `repo`. `attrs` takes `read` and
  `dispatch` booleans.
  """
  def create(%Repository{} = repo, source, attrs \\ %{}) when is_binary(source) do
    case resolve_source(String.trim(source)) do
      {:ok, source_attrs} ->
        %JobTokenGrant{}
        |> JobTokenGrant.changeset(
          attrs
          |> Map.new(fn {k, v} -> {to_string(k), v} end)
          |> Map.merge(source_attrs)
          |> Map.put("repository_id", repo.id)
        )
        |> Repo.insert()

      :error ->
        {:error, :unknown_source}
    end
  end

  defp resolve_source(source) do
    case String.split(source, "/") do
      [owner, name] when owner != "" and name != "" ->
        try do
          %Repository{id: id} = Repositories.get_repository_by_path!(owner, name)
          {:ok, %{"source_repository_id" => id}}
        rescue
          Ecto.NoResultsError -> :error
        end

      [handle] when handle != "" ->
        case Organizations.get_organization_by_handle(String.downcase(handle)) do
          %Organization{id: id} -> {:ok, %{"source_organization_id" => id}}
          _ -> :error
        end

      _ ->
        :error
    end
  end

  @doc "Remove grant `id` from `repo`. Grants on other repositories are left alone."
  def delete(%Repository{id: repo_id}, id) do
    case Repo.get_by(JobTokenGrant, id: id, repository_id: repo_id) do
      nil -> {:error, :not_found}
      grant -> Repo.delete(grant)
    end
  end

  @doc "`owner/name` or the organization handle a grant names."
  def source_label(%JobTokenGrant{source_repository: %Repository{} = repo}),
    do: Repositories.Storage.repo_handle(repo) <> "/" <> repo.name

  def source_label(%JobTokenGrant{source_organization: %Organization{handle: handle}}),
    do: handle <> " (all repositories)"
end