1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain
4
5import "slices"
6
7// The permissions of a job repository token (CI-3, D3) are the keys and levels
8// of the "permissions:" of a GitHub workflow. They are computed once, when the
9// job is planned, and stored as a map from key to level; a key that is absent
10// from the map is "none". What they allow on ermite is in RepoTokenScopes.
11
12// Permission levels, as a workflow spells them.
13const (
14 PermissionNone = "none"
15 PermissionRead = "read"
16 PermissionWrite = "write"
17)
18
19// Shorthands of a "permissions:" that is a string.
20const (
21 PermissionsReadAll = "read-all"
22 PermissionsWriteAll = "write-all"
23)
24
25// Permission keys ermite acts on.
26const (
27 PermContents = "contents"
28 PermPullRequests = "pull-requests"
29 // PermMetadata is always read: a workflow cannot set it.
30 PermMetadata = "metadata"
31)
32
33// permissionKey describes a key of "permissions:" (workflow-v1.0.json,
34// permissions-mapping).
35type permissionKey struct {
36 name string
37 // levels tell what the key accepts besides none: "read", "write" or both.
38 canRead, canWrite bool
39 // writeAll is the level "write-all" grants.
40 writeAll string
41}
42
43// permissionKeys are the keys a workflow can set. "write-all" keeps models and
44// vulnerability-alerts at read, as GitHub does; "read-all" grants read to the
45// keys that have one.
46var permissionKeys = []permissionKey{
47 {"actions", true, true, PermissionWrite},
48 {"artifact-metadata", true, true, PermissionWrite},
49 {"attestations", true, true, PermissionWrite},
50 {"checks", true, true, PermissionWrite},
51 {"code-quality", true, true, PermissionWrite},
52 {PermContents, true, true, PermissionWrite},
53 {"copilot-requests", false, true, PermissionWrite},
54 {"deployments", true, true, PermissionWrite},
55 {"discussions", true, true, PermissionWrite},
56 {"drives", true, true, PermissionWrite},
57 {"id-token", false, true, PermissionWrite},
58 {"issues", true, true, PermissionWrite},
59 {"models", true, false, PermissionRead},
60 {"packages", true, true, PermissionWrite},
61 {"pages", true, true, PermissionWrite},
62 {PermPullRequests, true, true, PermissionWrite},
63 {"repository-projects", true, true, PermissionWrite},
64 {"security-events", true, true, PermissionWrite},
65 {"statuses", true, true, PermissionWrite},
66 {"vulnerability-alerts", true, false, PermissionRead},
67}
68
69func findPermissionKey(name string) (permissionKey, bool) {
70 for _, k := range permissionKeys {
71 if k.name == name {
72 return k, true
73 }
74 }
75 return permissionKey{}, false
76}
77
78// PermissionSpec is a "permissions:" as written in a workflow or a job: a
79// shorthand (read-all, write-all) or a level per key. A nil *PermissionSpec is
80// no "permissions:" at all; an empty spec is "permissions: {}".
81type PermissionSpec struct {
82 All string
83 Levels map[string]string
84}
85
86// DefaultTokenPermissions are the permissions of a token whose workflow says
87// nothing: GitHub's restricted default (ermite offers no permissive setting).
88func DefaultTokenPermissions() map[string]string {
89 return map[string]string{PermContents: PermissionRead, PermMetadata: PermissionRead, "packages": PermissionRead}
90}
91
92// ResolvePermissions returns the permissions a job asks for: the job's
93// "permissions:" replaces the workflow's as a whole (GitHub does not merge
94// them), and metadata is always read. It returns nil when neither says anything:
95// the job then gets the default (TokenPermissions). A key or level ermite does
96// not know is none. The trust rule of the run is not applied here: it is applied
97// when the token is issued (TokenPermissions), so that a re-run of the same job
98// by another caller lowers the token without planning again.
99func ResolvePermissions(workflow, job *PermissionSpec) map[string]string {
100 spec := job
101 if spec == nil {
102 spec = workflow
103 }
104 if spec == nil {
105 return nil
106 }
107 out := map[string]string{PermMetadata: PermissionRead}
108 switch spec.All {
109 case PermissionsReadAll:
110 for _, k := range permissionKeys {
111 if k.canRead {
112 out[k.name] = PermissionRead
113 }
114 }
115 case PermissionsWriteAll:
116 for _, k := range permissionKeys {
117 out[k.name] = k.writeAll
118 }
119 default:
120 for name, level := range spec.Levels {
121 k, ok := findPermissionKey(name)
122 switch {
123 case !ok, level == PermissionNone:
124 case level == PermissionRead && k.canRead, level == PermissionWrite && k.canWrite:
125 out[name] = level
126 }
127 }
128 }
129 return out
130}
131
132// TokenPermissions returns the permissions a job token is issued with, given
133// those stored with the job: the default when none are stored (a job planned
134// before they existed), and, for a run that is not trusted (D1), every write
135// lowered to read (to none for a key that has no read). It copies its input.
136func TokenPermissions(stored map[string]string, trusted bool) map[string]string {
137 if len(stored) == 0 {
138 stored = DefaultTokenPermissions()
139 }
140 out := make(map[string]string, len(stored)+1)
141 for name, level := range stored {
142 if level == PermissionWrite && !trusted {
143 if k, ok := findPermissionKey(name); ok && !k.canRead {
144 continue
145 }
146 level = PermissionRead
147 }
148 if level == PermissionRead || level == PermissionWrite {
149 out[name] = level
150 }
151 }
152 out[PermMetadata] = PermissionRead
153 return out
154}
155
156// RepoTokenScopes maps the permissions of a job token to the scopes of the
157// principal on the run's repository: contents read is repo:read, contents write
158// adds repo:write, pull-requests write is mr:write. Never repo:admin, mr:merge,
159// secrets, tokens or CI scopes, whatever the permissions say. Permissions
160// without an effect on ermite grant nothing.
161func RepoTokenScopes(perms map[string]string) []Scope {
162 if len(perms) == 0 {
163 perms = DefaultTokenPermissions()
164 }
165 var scopes []Scope
166 switch perms[PermContents] {
167 case PermissionWrite:
168 scopes = append(scopes, ScopeRepoRead, ScopeRepoWrite)
169 case PermissionRead:
170 scopes = append(scopes, ScopeRepoRead)
171 }
172 if perms[PermPullRequests] == PermissionWrite {
173 scopes = append(scopes, ScopeMRWrite)
174 }
175 return scopes
176}
177
178// permissionRank orders the levels: none, read, write.
179func permissionRank(level string) int {
180 switch level {
181 case PermissionRead:
182 return 1
183 case PermissionWrite:
184 return 2
185 }
186 return 0
187}
188
189// PermissionViolations lists the keys that requested asks for above what max
190// allows (an absent key is none): a reusable workflow can only keep or lower the
191// permissions of its caller. The two slices hold "key: level" in the same
192// order, sorted by key, as GitHub's error message shows them.
193func PermissionViolations(requested, max map[string]string) (asked, allowed []string) {
194 keys := make([]string, 0, len(requested))
195 for k := range requested {
196 keys = append(keys, k)
197 }
198 slices.Sort(keys)
199 for _, k := range keys {
200 if permissionRank(requested[k]) > permissionRank(max[k]) {
201 lv := max[k]
202 if lv == "" {
203 lv = PermissionNone
204 }
205 asked = append(asked, k+": "+requested[k])
206 allowed = append(allowed, k+": "+lv)
207 }
208 }
209 return asked, allowed
210}