Skip to content
288 linesCodeBlameRaw
1//! `permissions:`: what a job's `GITHUB_TOKEN` (g1t's `G1T_TOKEN`) may
2//! do, written as on GitHub, at the workflow's top level or on a job, and
3//! the g1t scopes each grants.
4//!
5//! As on GitHub, a job's own `permissions` replace the workflow's; once
6//! either is written, every permission it leaves out is `none`, except
7//! `metadata`, which is always `read`. `read-all` and `write-all` set every
8//! one; `{}` sets none. A workflow that writes neither gets the
9//! repository's default: read-only (`contents: read`, `packages: read`),
10//! or every permission at `write` where the repository chose that.
11
12use std::collections::BTreeMap;
13
14use serde_json::Value;
15
16/// Every permission GitHub's token has, as workflows name them.
17pub const NAMES: [&str; 16] = [
18 "actions",
19 "attestations",
20 "checks",
21 "contents",
22 "deployments",
23 "discussions",
24 "id-token",
25 "issues",
26 "metadata",
27 "models",
28 "packages",
29 "pages",
30 "pull-requests",
31 "repository-projects",
32 "security-events",
33 "statuses",
34];
35
36/// Permissions g1t has nothing behind: accepted, and they grant nothing.
37pub const WITHOUT_EFFECT: [&str; 5] = ["attestations", "discussions", "id-token", "models", "repository-projects"];
38
39/// How much of one permission.
40#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
41pub enum Access {
42 #[default]
43 None,
44 Read,
45 Write,
46}
47
48impl Access {
49 fn parse(text: &str) -> Option<Access> {
50 match text.trim().to_ascii_lowercase().as_str() {
51 "none" => Some(Access::None),
52 "read" => Some(Access::Read),
53 "write" => Some(Access::Write),
54 _ => None,
55 }
56 }
57
58 pub fn as_str(self) -> &'static str {
59 match self {
60 Access::None => "none",
61 Access::Read => "read",
62 Access::Write => "write",
63 }
64 }
65}
66
67/// A token's permissions: each name's level; a name left out is `none`.
68#[derive(Clone, Debug, Default, PartialEq, Eq)]
69pub struct Permissions {
70 levels: BTreeMap<&'static str, Access>,
71}
72
73/// The repository's choice for workflows that write no `permissions`.
74#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
75pub enum TokenDefault {
76 /// `contents: read` and `packages: read`: g1t's default.
77 #[default]
78 Restricted,
79 /// Every permission at `write`.
80 Permissive,
81}
82
83impl TokenDefault {
84 pub fn parse(text: &str) -> Option<TokenDefault> {
85 match text.trim() {
86 "read" | "restricted" => Some(TokenDefault::Restricted),
87 "write" | "permissive" => Some(TokenDefault::Permissive),
88 _ => None,
89 }
90 }
91
92 /// As the API names it: `read` or `write`, as GitHub's
93 /// `default_workflow_permissions` does.
94 pub fn as_str(self) -> &'static str {
95 match self {
96 TokenDefault::Restricted => "read",
97 TokenDefault::Permissive => "write",
98 }
99 }
100}
101
102impl Permissions {
103 /// Every permission at `access`.
104 pub fn all(access: Access) -> Permissions {
105 Permissions { levels: NAMES.iter().map(|name| (*name, access)).collect() }
106 }
107
108 /// What a workflow that writes no `permissions` gets.
109 pub fn default_for(default: TokenDefault) -> Permissions {
110 match default {
111 TokenDefault::Permissive => Permissions::all(Access::Write),
112 TokenDefault::Restricted => {
113 let mut permissions = Permissions::default();
114 permissions.set("contents", Access::Read);
115 permissions.set("packages", Access::Read);
116 permissions
117 }
118 }
119 }
120
121 fn set(&mut self, name: &str, access: Access) {
122 if let Some(name) = NAMES.iter().find(|known| **known == name) {
123 self.levels.insert(name, access);
124 }
125 }
126
127 /// One permission's level. `metadata` is always at least `read`.
128 pub fn get(&self, name: &str) -> Access {
129 let level = self.levels.get(name).copied().unwrap_or_default();
130 if name == "metadata" { level.max(Access::Read) } else { level }
131 }
132
133 /// The same, with nothing above `read`: a pull request from outside
134 /// the repository gets no more, whatever its workflow asks for.
135 pub fn read_only(&self) -> Permissions {
136 Permissions { levels: self.levels.iter().map(|(name, access)| (*name, (*access).min(Access::Read))).collect() }
137 }
138
139 /// Each permission at the lower of this and `cap`: a called workflow's
140 /// jobs get no more than the job that calls it.
141 pub fn capped_by(&self, cap: &Permissions) -> Permissions {
142 Permissions { levels: NAMES.iter().map(|name| (*name, self.get(name).min(cap.get(name)))).collect() }
143 }
144
145 /// Each permission and its level, `metadata` included, in name order,
146 /// as the run's page and the job's log show them.
147 pub fn listed(&self) -> Vec<(&'static str, Access)> {
148 NAMES.iter().map(|name| (*name, self.get(name))).collect()
149 }
150
151 /// The g1t scopes the token is given, as `resource:level`.
152 pub fn scopes(&self) -> Vec<&'static str> {
153 let mut scopes: Vec<&'static str> = vec!["repo:read"];
154 let mut add = |name: &str, read: &[&'static str], write: &[&'static str]| match self.get(name) {
155 Access::None => {}
156 Access::Read => scopes.extend_from_slice(read),
157 Access::Write => {
158 scopes.extend_from_slice(read);
159 scopes.extend_from_slice(write);
160 }
161 };
162 add("contents", &["code:read"], &["code:write", "repo:write"]);
163 add("pull-requests", &["pull_requests:read"], &["pull_requests:write"]);
164 add("issues", &["issues:read"], &["issues:write"]);
165 add("actions", &["workflows:read"], &["workflows:write"]);
166 add("checks", &["checks:read"], &["checks:write"]);
167 add("statuses", &["checks:read"], &["checks:write"]);
168 add("deployments", &["deployments:read"], &["deployments:write"]);
169 add("pages", &["deployments:read"], &["deployments:write"]);
170 add("packages", &["packages:read"], &["packages:write"]);
171 add("security-events", &["security:read"], &["security:write"]);
172 let mut seen = Vec::new();
173 scopes.retain(|scope| {
174 let fresh = !seen.contains(scope);
175 seen.push(*scope);
176 fresh
177 });
178 scopes
179 }
180}
181
182/// Reads a `permissions:` value. `Err` names what is wrong with it; the
183/// second part of `Ok` lists names it does not know, which grant nothing.
184pub fn parse(value: &Value) -> Result<(Permissions, Vec<String>), String> {
185 match value {
186 Value::String(text) => match text.trim() {
187 "read-all" => Ok((Permissions::all(Access::Read), Vec::new())),
188 "write-all" => Ok((Permissions::all(Access::Write), Vec::new())),
189 other => Err(format!("`permissions: {other}` is not `read-all`, `write-all` or a mapping of permissions to `read`, `write` or `none`.")),
190 },
191 Value::Object(map) => {
192 let mut permissions = Permissions::default();
193 let mut unknown = Vec::new();
194 for (name, level) in map {
195 let Some(access) = level.as_str().and_then(Access::parse) else {
196 return Err(format!("`permissions.{name}` is `read`, `write` or `none`."));
197 };
198 if NAMES.contains(&name.as_str()) {
199 permissions.set(name, access);
200 } else {
201 unknown.push(name.clone());
202 }
203 }
204 Ok((permissions, unknown))
205 }
206 Value::Null => Ok((Permissions::default(), Vec::new())),
207 _ => Err("`permissions` is `read-all`, `write-all` or a mapping of permissions to `read`, `write` or `none`.".to_owned()),
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use serde_json::json;
215
216 #[test]
217 fn the_default_is_read_only() {
218 let restricted = Permissions::default_for(TokenDefault::Restricted);
219 assert_eq!(restricted.get("contents"), Access::Read);
220 assert_eq!(restricted.get("packages"), Access::Read);
221 assert_eq!(restricted.get("issues"), Access::None);
222 assert_eq!(restricted.get("metadata"), Access::Read);
223 assert_eq!(restricted.scopes(), ["repo:read", "code:read", "packages:read"]);
224 let permissive = Permissions::default_for(TokenDefault::Permissive);
225 assert!(permissive.scopes().contains(&"code:write"));
226 assert!(permissive.scopes().contains(&"pull_requests:write"));
227 assert_eq!(TokenDefault::parse("write"), Some(TokenDefault::Permissive));
228 assert_eq!(TokenDefault::parse("read"), Some(TokenDefault::Restricted));
229 }
230
231 #[test]
232 fn written_permissions_leave_the_rest_at_none() {
233 let (permissions, unknown) = parse(&json!({ "contents": "write", "pull-requests": "write", "issues": "read" })).unwrap();
234 assert!(unknown.is_empty());
235 assert_eq!(
236 permissions.scopes(),
237 ["repo:read", "code:read", "code:write", "repo:write", "pull_requests:read", "pull_requests:write", "issues:read"]
238 );
239 assert_eq!(permissions.get("packages"), Access::None);
240 // `{}` is nothing but metadata.
241 let (none, _) = parse(&json!({})).unwrap();
242 assert_eq!(none.scopes(), ["repo:read"]);
243 }
244
245 #[test]
246 fn every_permission_has_its_scopes() {
247 let (all, _) = parse(&json!("write-all")).unwrap();
248 let scopes = all.scopes();
249 for scope in [
250 "workflows:write", "checks:write", "deployments:write", "packages:write", "security:write", "issues:write",
251 ] {
252 assert!(scopes.contains(&scope), "{scope}");
253 }
254 // Statuses and checks are one resource on g1t; each scope once.
255 let (statuses, _) = parse(&json!({ "statuses": "write", "checks": "read" })).unwrap();
256 assert_eq!(statuses.scopes(), ["repo:read", "checks:read", "checks:write"]);
257 // What g1t has nothing behind grants nothing.
258 let (oidc, _) = parse(&json!({ "id-token": "write", "discussions": "write" })).unwrap();
259 assert_eq!(oidc.scopes(), ["repo:read"]);
260 }
261
262 #[test]
263 fn outside_pull_requests_read_only() {
264 let (permissions, _) = parse(&json!("write-all")).unwrap();
265 let capped = permissions.read_only();
266 assert!(capped.scopes().iter().all(|scope| scope.ends_with(":read")), "{:?}", capped.scopes());
267 assert_eq!(capped.get("contents"), Access::Read);
268 }
269
270 #[test]
271 fn a_called_workflow_gets_no_more_than_its_caller() {
272 let (callee, _) = parse(&json!("write-all")).unwrap();
273 let (caller, _) = parse(&json!({ "contents": "write", "issues": "read" })).unwrap();
274 let capped = callee.capped_by(&caller);
275 assert_eq!(capped.get("contents"), Access::Write);
276 assert_eq!(capped.get("issues"), Access::Read);
277 assert_eq!(capped.get("pull-requests"), Access::None);
278 }
279
280 #[test]
281 fn mistakes_and_unknown_names() {
282 assert!(parse(&json!("read")).is_err());
283 assert!(parse(&json!({ "contents": "admin" })).is_err());
284 assert!(parse(&json!(["contents"])).is_err());
285 let (_, unknown) = parse(&json!({ "contents": "read", "wiki": "write" })).unwrap();
286 assert_eq!(unknown, ["wiki"]);
287 }
288}