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