4.9 KiB · text History b868907
defmodule GitGud.Workflows.Permissions do
  @moduledoc """
  What a job's token may do, from the workflow's `permissions:`.

  As on GitHub: `permissions:` at the top of the workflow, replaced
  entirely (no merge) by a job's own `permissions:`. The value is
  `read-all`, `write-all`, `{}` (nothing), or a map of scope to `read`,
  `write` or `none`, in which every scope left out is `none`.

  Five scopes mean something to gitgud:

    * `contents` — `read` clones and fetches the job's repository over
      HTTP, `write` also pushes to it (branch protection still applies)
    * `packages` — `read` pulls the repository's packages and images,
      and those of repositories that granted it read; `write` publishes
    * `actions` — `write` may dispatch workflows, in this repository or
      one whose job-token grants allow it
    * `issues` — over the mobile API, in the job's own repository:
      `read` lists and reads issues; `write` opens them, comments, and
      closes or reopens the ones it opened
    * `pull-requests` — likewise: `read`; `write` opens pull requests and
      comments, and edits, labels, closes or reopens its own. Never
      reviews or merges, whatever the level: a pipeline approving its
      own change would walk around branch protection's approvals

  What a job opens is authored by the forge's CI account
  (`GitGud.Accounts.ci_user!/0`) and records the run it came from.

  `metadata` is always `read`. Every other GitHub scope (`statuses`,
  `checks`, `deployments`, `id-token`, …) is accepted and ignored: a job
  token can't do those things here at all.

  With no `permissions:` anywhere a job keeps what job tokens could always
  do — `contents: read`, `packages: write`, `actions: write`, and nothing
  on issues or pull requests — rather than GitHub's permissive default,
  which would also let it push.
  """

  require Logger

  @type level :: :none | :read | :write
  @type t :: %{String.t() => level()}

  @scopes ~w(contents packages actions issues pull-requests)

  @default %{"contents" => :read, "packages" => :write, "actions" => :write}

  @doc "The scopes that mean something here."
  def scopes, do: @scopes

  @doc "What a job gets when its workflow says nothing."
  @spec default() :: t()
  def default, do: @default

  @doc """
  The effective permissions for a job: its own `permissions:` if it has
  the key, else the workflow's, else `default/0`.
  """
  @spec effective(map() | nil, map() | nil) :: t()
  def effective(workflow_doc, job_definition) do
    cond do
      is_map(job_definition) and Map.has_key?(job_definition, "permissions") ->
        parse(job_definition["permissions"])

      is_map(workflow_doc) and Map.has_key?(workflow_doc, "permissions") ->
        parse(workflow_doc["permissions"])

      true ->
        @default
    end
  end

  defp parse("read-all"), do: all(:read)
  defp parse("write-all"), do: all(:write)

  defp parse(%{} = map) do
    Map.new(@scopes, fn scope -> {scope, level(scope, Map.get(map, scope, "none"))} end)
  end

  defp parse(other) do
    Logger.warning("workflow permissions: #{inspect(other)} is not understood; granting none")
    all(:none)
  end

  defp level(_scope, "read"), do: :read
  defp level(_scope, "write"), do: :write
  defp level(_scope, "none"), do: :none

  defp level(scope, other) do
    Logger.warning("workflow permissions: #{scope}: #{inspect(other)} is not understood; none")
    :none
  end

  defp all(level), do: Map.new(@scopes, &{&1, level})

  @doc """
  One hop, no more: a run started by a pull request a job opened (the
  update PR's own CI) keeps what its workflow grants, except `issues`
  and `pull-requests`, which are `none` — so a pipeline can't open
  something whose run opens something, and so on.

  `opened_by_job?` says whether the run's pull request was opened with a
  job token (`GitGud.Workflows.opened_by_job?/1`).
  """
  @spec one_hop(t(), boolean()) :: t()
  def one_hop(perms, false), do: perms

  def one_hop(perms, true),
    do: Map.merge(perms, %{"issues" => :none, "pull-requests" => :none})

  @doc "As carried in the job token's `perm` claim: string values."
  @spec to_claim(t()) :: %{String.t() => String.t()}
  def to_claim(perms), do: Map.new(perms, fn {scope, level} -> {scope, Atom.to_string(level)} end)

  @doc """
  Does a token's `perm` claim allow `level` on `scope`? A token minted
  before the claim existed has `default/0`. `metadata` is always readable.
  """
  @spec permits?(map() | nil, String.t(), :read | :write) :: boolean()
  def permits?(_claim, "metadata", :read), do: true

  def permits?(claim, scope, wanted) when wanted in [:read, :write] do
    have =
      case claim do
        %{} -> claim |> Map.get(scope, "none") |> claim_level()
        nil -> Map.get(@default, scope, :none)
      end

    rank(have) >= rank(wanted)
  end

  defp claim_level("read"), do: :read
  defp claim_level("write"), do: :write
  defp claim_level(_), do: :none

  defp rank(:none), do: 0
  defp rank(:read), do: 1
  defp rank(:write), do: 2
end