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 safe | 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 | ||
| 12 | use std::collections::BTreeMap; | |
| 13 | ||
| 14 | use serde_json::Value; | |
| 15 | ||
| 16 | /// Every permission GitHub's token has, as workflows name them. | |
| 17 | pub 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 branch | 36 | /// 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 safe | 39 | pub 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)] | |
| 43 | pub enum Access { | |
| 44 | #[default] | |
| 45 | None, | |
| 46 | Read, | |
| 47 | Write, | |
| 48 | } | |
| 49 | ||
| 50 | impl 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)] | |
| 71 | pub 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)] | |
| 77 | pub enum TokenDefault { | |
| 78 | /// `contents: read` and `packages: read`: g1t's default. | |
| 79 | #[default] | |
| 80 | Restricted, | |
| 81 | /// Every permission at `write`. | |
| 82 | Permissive, | |
| 83 | } | |
| 84 | ||
| 85 | impl 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 | ||
| 104 | impl 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. | |
| 186 | pub 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)] | |
| 214 | mod 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.