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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
4.9 KiB · text
History
d3fedc5