token_permissions.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6	"sort"
7	"strings"
8
9	"bnema.dev/ermite-actions/workflow"
10)
11
12// tokenScope is a scope of the GITHUB_TOKEN as GitHub's runner lists it.
13type tokenScope struct {
14	// key is the name in a workflow's "permissions" mapping.
15	key string
16	// display is the name "Set up job" prints.
17	display string
18	// writeAll is the level "write-all" grants: GitHub keeps models and
19	// vulnerability-alerts at read, whatever the workflow asks.
20	writeAll string
21	// readAll is the level "read-all" grants, "" when it grants nothing.
22	readAll string
23}
24
25// tokenScopes are the scopes GitHub lists in "GITHUB_TOKEN Permissions" for
26// "read-all" and "write-all" (recordings/permissions-display.json: p-read-all,
27// p-write-all). "id-token" is not here: it is never listed, with any level
28// (p-job-mixed asks for id-token: write). "copilot-requests" is listed for
29// write-all only (read-all grants nothing for it, and "read" is rejected).
30// "metadata" is not a scope a workflow can set (rejected); it is always read.
31var tokenScopes = []tokenScope{
32	{"actions", "Actions", "write", "read"},
33	{"artifact-metadata", "ArtifactMetadata", "write", "read"},
34	{"attestations", "Attestations", "write", "read"},
35	{"checks", "Checks", "write", "read"},
36	{"code-quality", "CodeQuality", "write", "read"},
37	{"contents", "Contents", "write", "read"},
38	{"copilot-requests", "CopilotRequests", "write", ""},
39	{"deployments", "Deployments", "write", "read"},
40	{"discussions", "Discussions", "write", "read"},
41	{"drives", "Drives", "write", "read"},
42	{"issues", "Issues", "write", "read"},
43	{"models", "Models", "read", "read"},
44	{"packages", "Packages", "write", "read"},
45	{"pages", "Pages", "write", "read"},
46	{"pull-requests", "PullRequests", "write", "read"},
47	{"repository-projects", "RepositoryProjects", "write", "read"},
48	{"security-events", "SecurityEvents", "write", "read"},
49	{"statuses", "Statuses", "write", "read"},
50	{"vulnerability-alerts", "VulnerabilityAlerts", "read", "read"},
51}
52
53// permissionLine is one line of the group: "<Display>: <level>".
54type permissionLine struct{ name, level string }
55
56// tokenPermissionLines returns the lines of the "GITHUB_TOKEN Permissions"
57// group for the effective permissions of a job, as GitHub prints them on a
58// push by a repository owner (the recorded case):
59//
60//   - the permissions of the job replace those of the workflow (no merge);
61//     without either key the repository default applies;
62//   - lines are sorted by display name (ordinal), Metadata included;
63//   - Metadata is always listed, as read, whatever the workflow says;
64//   - a scope set to "none" or left out is not listed, nor is id-token;
65//   - "permissions: {}" lists Metadata only;
66//   - "read-all" lists every scope but copilot-requests as read;
67//   - "write-all" lists every scope as write except models and
68//     vulnerability-alerts (read);
69//   - a mapping lists its scopes with the given level; a scope that GitHub
70//     does not know is left out (it rejects the workflow before this point
71//     for the invalid ones it was shown).
72//
73// Not recorded: a private repository, a fork pull request, an organisation
74// whose default token is restricted differently, a "pull_request_target"
75// event. They change what the server grants; the workflow side is the same.
76func tokenPermissionLines(wf *workflow.Workflow, jb *workflow.Job) []permissionLine {
77	p := jb.Permissions
78	if p == nil {
79		p = wf.Permissions
80	}
81	if p == nil {
82		return []permissionLine{{"Contents", "read"}, {"Metadata", "read"}, {"Packages", "read"}}
83	}
84	lines := []permissionLine{{"Metadata", "read"}}
85	switch p.All {
86	case "read-all":
87		for _, s := range tokenScopes {
88			if s.readAll != "" {
89				lines = append(lines, permissionLine{s.display, s.readAll})
90			}
91		}
92	case "write-all":
93		for _, s := range tokenScopes {
94			lines = append(lines, permissionLine{s.display, s.writeAll})
95		}
96	default:
97		level := map[string]string{}
98		for _, sc := range p.Scopes {
99			level[sc.Scope] = sc.Level
100		}
101		for _, s := range tokenScopes {
102			if l := level[s.key]; l == "read" || l == "write" {
103				lines = append(lines, permissionLine{s.display, l})
104			}
105		}
106	}
107	sort.Slice(lines, func(a, b int) bool { return lines[a].name < lines[b].name })
108	return lines
109}
110
111// serverPermissionLines returns the lines of the group for the permissions the
112// server sent (Job.permissions): one per key at "read" or "write", sorted by
113// display name like GitHub's runner prints them. Metadata is always listed as
114// read. A key at "none" or absent is not listed, nor is id-token (GitHub never
115// lists it); a key the runner does not know is listed under its name in Pascal
116// case, as the known ones are ("pull-requests" is "PullRequests").
117func serverPermissionLines(levels map[string]string) []permissionLine {
118	lines := []permissionLine{{"Metadata", "read"}}
119	for key, level := range levels {
120		if (level != "read" && level != "write") || key == "metadata" || key == "id-token" {
121			continue
122		}
123		lines = append(lines, permissionLine{displayName(key), level})
124	}
125	sort.Slice(lines, func(a, b int) bool { return lines[a].name < lines[b].name })
126	return lines
127}
128
129// displayName is the name "Set up job" prints for a permission key.
130func displayName(key string) string {
131	for _, s := range tokenScopes {
132		if s.key == key {
133			return s.display
134		}
135	}
136	var b strings.Builder
137	for _, part := range strings.Split(key, "-") {
138		if part != "" {
139			b.WriteString(strings.ToUpper(part[:1]) + part[1:])
140		}
141	}
142	return b.String()
143}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close