// SPDX-License-Identifier: MIT package executor import ( "sort" "strings" "bnema.dev/ermite-actions/workflow" ) // tokenScope is a scope of the GITHUB_TOKEN as GitHub's runner lists it. type tokenScope struct { // key is the name in a workflow's "permissions" mapping. key string // display is the name "Set up job" prints. display string // writeAll is the level "write-all" grants: GitHub keeps models and // vulnerability-alerts at read, whatever the workflow asks. writeAll string // readAll is the level "read-all" grants, "" when it grants nothing. readAll string } // tokenScopes are the scopes GitHub lists in "GITHUB_TOKEN Permissions" for // "read-all" and "write-all" (recordings/permissions-display.json: p-read-all, // p-write-all). "id-token" is not here: it is never listed, with any level // (p-job-mixed asks for id-token: write). "copilot-requests" is listed for // write-all only (read-all grants nothing for it, and "read" is rejected). // "metadata" is not a scope a workflow can set (rejected); it is always read. var tokenScopes = []tokenScope{ {"actions", "Actions", "write", "read"}, {"artifact-metadata", "ArtifactMetadata", "write", "read"}, {"attestations", "Attestations", "write", "read"}, {"checks", "Checks", "write", "read"}, {"code-quality", "CodeQuality", "write", "read"}, {"contents", "Contents", "write", "read"}, {"copilot-requests", "CopilotRequests", "write", ""}, {"deployments", "Deployments", "write", "read"}, {"discussions", "Discussions", "write", "read"}, {"drives", "Drives", "write", "read"}, {"issues", "Issues", "write", "read"}, {"models", "Models", "read", "read"}, {"packages", "Packages", "write", "read"}, {"pages", "Pages", "write", "read"}, {"pull-requests", "PullRequests", "write", "read"}, {"repository-projects", "RepositoryProjects", "write", "read"}, {"security-events", "SecurityEvents", "write", "read"}, {"statuses", "Statuses", "write", "read"}, {"vulnerability-alerts", "VulnerabilityAlerts", "read", "read"}, } // permissionLine is one line of the group: ": ". type permissionLine struct{ name, level string } // tokenPermissionLines returns the lines of the "GITHUB_TOKEN Permissions" // group for the effective permissions of a job, as GitHub prints them on a // push by a repository owner (the recorded case): // // - the permissions of the job replace those of the workflow (no merge); // without either key the repository default applies; // - lines are sorted by display name (ordinal), Metadata included; // - Metadata is always listed, as read, whatever the workflow says; // - a scope set to "none" or left out is not listed, nor is id-token; // - "permissions: {}" lists Metadata only; // - "read-all" lists every scope but copilot-requests as read; // - "write-all" lists every scope as write except models and // vulnerability-alerts (read); // - a mapping lists its scopes with the given level; a scope that GitHub // does not know is left out (it rejects the workflow before this point // for the invalid ones it was shown). // // Not recorded: a private repository, a fork pull request, an organisation // whose default token is restricted differently, a "pull_request_target" // event. They change what the server grants; the workflow side is the same. func tokenPermissionLines(wf *workflow.Workflow, jb *workflow.Job) []permissionLine { p := jb.Permissions if p == nil { p = wf.Permissions } if p == nil { return []permissionLine{{"Contents", "read"}, {"Metadata", "read"}, {"Packages", "read"}} } lines := []permissionLine{{"Metadata", "read"}} switch p.All { case "read-all": for _, s := range tokenScopes { if s.readAll != "" { lines = append(lines, permissionLine{s.display, s.readAll}) } } case "write-all": for _, s := range tokenScopes { lines = append(lines, permissionLine{s.display, s.writeAll}) } default: level := map[string]string{} for _, sc := range p.Scopes { level[sc.Scope] = sc.Level } for _, s := range tokenScopes { if l := level[s.key]; l == "read" || l == "write" { lines = append(lines, permissionLine{s.display, l}) } } } sort.Slice(lines, func(a, b int) bool { return lines[a].name < lines[b].name }) return lines } // serverPermissionLines returns the lines of the group for the permissions the // server sent (Job.permissions): one per key at "read" or "write", sorted by // display name like GitHub's runner prints them. Metadata is always listed as // read. A key at "none" or absent is not listed, nor is id-token (GitHub never // lists it); a key the runner does not know is listed under its name in Pascal // case, as the known ones are ("pull-requests" is "PullRequests"). func serverPermissionLines(levels map[string]string) []permissionLine { lines := []permissionLine{{"Metadata", "read"}} for key, level := range levels { if (level != "read" && level != "write") || key == "metadata" || key == "id-token" { continue } lines = append(lines, permissionLine{displayName(key), level}) } sort.Slice(lines, func(a, b int) bool { return lines[a].name < lines[b].name }) return lines } // displayName is the name "Set up job" prints for a permission key. func displayName(key string) string { for _, s := range tokenScopes { if s.key == key { return s.display } } var b strings.Builder for _, part := range strings.Split(key, "-") { if part != "" { b.WriteString(strings.ToUpper(part[:1]) + part[1:]) } } return b.String() }