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`.
Three 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
`metadata` is always `read`. Every other GitHub scope (`issues`,
`pull-requests`, `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` — 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)
@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 "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.
3.8 KiB · text
History
a9f5a09