Skip to content
290 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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

This file's history is long; its oldest lines are credited to the oldest commit read.