Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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