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