v0.17.0 ab8e178

ci_permissions.go RawHistoryFolder

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}

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 2

Tags 44

↑↓ moveenter openesc close