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}