gha_github_service_proof/
permissions.rs1use anyhow::{Result, bail};
2use serde_json::Value;
3use serde_yaml::Value as Yaml;
4use std::collections::BTreeMap;
5
6use crate::model::{
7 Check, PermissionKey, PermissionLevel, PermissionResolution, PermissionScope, PermissionSet,
8 PermissionSource, RequiredPermission,
9};
10
11const ALL_LEVELS_READ: &str = "read-all";
12const ALL_LEVELS_WRITE: &str = "write-all";
13
14const METADATA_KEY: &str = "metadata";
20
21pub fn parse_yaml_block(value: Option<&Yaml>) -> (Option<PermissionSet>, Vec<Check>) {
22 let Some(value) = value else {
23 return (None, Vec::new());
24 };
25
26 let mut checks = Vec::new();
27 let mut set = PermissionSet::default();
28
29 match value {
30 Yaml::String(text) => {
31 let trimmed = text.trim();
32 match trimmed {
33 ALL_LEVELS_READ => {
34 set.shorthand = Some(ALL_LEVELS_READ.to_owned());
35 for key in PermissionKey::all() {
36 if matches!(key, PermissionKey::IdToken) {
37 continue;
39 }
40 set.entries
41 .insert(key.as_str().to_owned(), PermissionLevel::Read);
42 }
43 }
44 ALL_LEVELS_WRITE => {
45 set.shorthand = Some(ALL_LEVELS_WRITE.to_owned());
46 for key in PermissionKey::all() {
47 if matches!(key, PermissionKey::IdToken) {
48 continue;
49 }
50 set.entries
51 .insert(key.as_str().to_owned(), PermissionLevel::Write);
52 }
53 }
54 "" => {
55 checks.push(Check::fail(
56 "permissions.empty_scalar",
57 "permissions: cannot be an empty scalar",
58 ));
59 }
60 other => {
61 checks.push(Check::fail(
62 "permissions.unknown_scalar",
63 format!(
64 "permissions scalar must be 'read-all' or 'write-all', got '{other}'"
65 ),
66 ));
67 }
68 }
69 }
70 Yaml::Mapping(map) => {
71 for (key_value, level_value) in map {
72 let Yaml::String(key) = key_value else {
73 checks.push(Check::fail(
74 "permissions.non_string_key",
75 "permission keys must be strings",
76 ));
77 continue;
78 };
79 let Some(level_text) = scalar_text(level_value) else {
80 checks.push(Check::fail(
81 "permissions.non_string_level",
82 format!("permission '{key}' must map to a string level"),
83 ));
84 continue;
85 };
86 let Some(level) = PermissionLevel::parse(&level_text) else {
87 checks.push(Check::fail(
88 "permissions.unknown_level",
89 format!(
90 "permission '{key}': level '{level_text}' is not one of none|read|write"
91 ),
92 ));
93 continue;
94 };
95 if key == METADATA_KEY {
96 set.unknown_keys.push(key.clone());
97 checks.push(Check::fail(
98 "permissions.metadata_not_configurable",
99 "workflow `permissions:` syntax does not expose `metadata`; GitHub App installation tokens grant `metadata: read` implicitly. Remove this key.",
100 ));
101 set.entries.insert(key.clone(), level);
102 continue;
103 }
104 if PermissionKey::parse(key).is_none() {
105 set.unknown_keys.push(key.clone());
106 checks.push(Check::warn(
107 "permissions.unknown_key",
108 format!("permission key '{key}' is not a recognized GitHub permission"),
109 ));
110 }
111 set.entries.insert(key.clone(), level);
112 }
113 }
114 _ => {
115 checks.push(Check::fail(
116 "permissions.invalid_shape",
117 "permissions must be a scalar (read-all|write-all) or a mapping",
118 ));
119 }
120 }
121
122 (Some(set), checks)
123}
124
125fn scalar_text(value: &Yaml) -> Option<String> {
126 match value {
127 Yaml::String(text) => Some(text.clone()),
128 Yaml::Bool(b) => Some(b.to_string()),
129 Yaml::Number(n) => Some(n.to_string()),
130 _ => None,
131 }
132}
133
134pub fn parse_json_permissions(value: &Value) -> Result<PermissionSet> {
135 let mut set = PermissionSet::default();
136 match value {
137 Value::Null => Ok(set),
138 Value::String(text) => match text.as_str() {
139 ALL_LEVELS_READ => {
140 set.shorthand = Some(ALL_LEVELS_READ.to_owned());
141 for key in PermissionKey::all() {
142 if matches!(key, PermissionKey::IdToken) {
143 continue;
144 }
145 set.entries
146 .insert(key.as_str().to_owned(), PermissionLevel::Read);
147 }
148 Ok(set)
149 }
150 ALL_LEVELS_WRITE => {
151 set.shorthand = Some(ALL_LEVELS_WRITE.to_owned());
152 for key in PermissionKey::all() {
153 if matches!(key, PermissionKey::IdToken) {
154 continue;
155 }
156 set.entries
157 .insert(key.as_str().to_owned(), PermissionLevel::Write);
158 }
159 Ok(set)
160 }
161 other => bail!("permissions scalar must be 'read-all' or 'write-all', got '{other}'"),
162 },
163 Value::Object(map) => {
164 for (key, level) in map {
165 let Value::String(level_text) = level else {
166 bail!("permission '{key}' must map to a string level");
167 };
168 let Some(parsed) = PermissionLevel::parse(level_text) else {
169 bail!("permission '{key}': level '{level_text}' is not one of none|read|write");
170 };
171 if key == METADATA_KEY {
172 match parsed {
173 PermissionLevel::Read => {
174 set.entries.insert(key.clone(), parsed);
177 continue;
178 }
179 PermissionLevel::Write => {
180 bail!(
181 "permission 'metadata': installation tokens grant `metadata: read` implicitly; `metadata: write` is not a valid GitHub installation permission"
182 );
183 }
184 PermissionLevel::None => {
185 bail!(
186 "permission 'metadata': installation tokens always retain implicit `metadata: read`; `metadata: none` cannot be granted"
187 );
188 }
189 }
190 }
191 if PermissionKey::parse(key).is_none() {
192 set.unknown_keys.push(key.clone());
193 }
194 set.entries.insert(key.clone(), parsed);
195 }
196 Ok(set)
197 }
198 _ => bail!("permissions must be null, a string shorthand, or an object mapping"),
199 }
200}
201
202pub fn resolve(
203 workflow_permissions: Option<PermissionSet>,
204 job_permissions: Option<PermissionSet>,
205 scope: PermissionScope,
206) -> PermissionResolution {
207 let (effective, source) = match (&job_permissions, &workflow_permissions, scope) {
208 (Some(job), _, PermissionScope::Job) => (job.clone(), PermissionSource::JobBlock),
209 (None, Some(wf), PermissionScope::Job) => (wf.clone(), PermissionSource::WorkflowBlock),
210 (_, Some(wf), PermissionScope::Workflow) => (wf.clone(), PermissionSource::WorkflowBlock),
211 _ => (default_restricted(), PermissionSource::DefaultRestricted),
212 };
213
214 let mut checks = Vec::new();
215 if matches!(source, PermissionSource::DefaultRestricted) {
216 checks.push(Check::warn(
217 "permissions.default_restricted",
218 "no permissions block found at workflow or job level; assuming restricted defaults (contents: read). Set an explicit permissions block to pin behavior.",
219 ));
220 }
221
222 PermissionResolution {
223 scope,
224 workflow_permissions,
225 job_permissions,
226 effective,
227 source,
228 checks,
229 }
230}
231
232pub fn default_restricted() -> PermissionSet {
233 let mut set = PermissionSet::default();
234 set.entries.insert(
235 PermissionKey::Contents.as_str().to_owned(),
236 PermissionLevel::Read,
237 );
238 set
239}
240
241pub fn missing(set: &PermissionSet, required: &[RequiredPermission]) -> Vec<RequiredPermission> {
242 required
243 .iter()
244 .filter(|req| !set.level(req.key).satisfies(req.level))
245 .cloned()
246 .collect()
247}
248
249pub fn satisfies(set: &PermissionSet, required: &[RequiredPermission]) -> bool {
250 missing(set, required).is_empty()
251}
252
253pub fn effective_map(set: &PermissionSet) -> BTreeMap<String, PermissionLevel> {
254 set.entries.clone()
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use serde_yaml::from_str;
261
262 #[test]
263 fn parse_yaml_mapping_block() {
264 let yaml: Yaml = from_str("contents: write\nissues: read\n").unwrap();
265 let (set, checks) = parse_yaml_block(Some(&yaml));
266 let set = set.unwrap();
267 assert_eq!(set.level(PermissionKey::Contents), PermissionLevel::Write);
268 assert_eq!(set.level(PermissionKey::Issues), PermissionLevel::Read);
269 assert_eq!(
270 set.level(PermissionKey::PullRequests),
271 PermissionLevel::None
272 );
273 assert!(
274 checks
275 .iter()
276 .all(|c| !matches!(c.status, crate::model::CheckStatus::Fail))
277 );
278 }
279
280 #[test]
281 fn parse_yaml_read_all_shortcut() {
282 let yaml: Yaml = from_str("read-all").unwrap();
283 let (set, _) = parse_yaml_block(Some(&yaml));
284 let set = set.unwrap();
285 assert_eq!(set.level(PermissionKey::Contents), PermissionLevel::Read);
286 assert_eq!(set.level(PermissionKey::Issues), PermissionLevel::Read);
287 assert_eq!(set.level(PermissionKey::IdToken), PermissionLevel::None);
289 assert_eq!(set.shorthand.as_deref(), Some("read-all"));
290 }
291
292 #[test]
293 fn parse_yaml_unknown_key_warns_but_keeps_value() {
294 let yaml: Yaml = from_str("bogus: write\n").unwrap();
295 let (set, checks) = parse_yaml_block(Some(&yaml));
296 let set = set.unwrap();
297 assert!(set.unknown_keys.iter().any(|k| k == "bogus"));
298 assert!(checks.iter().any(|c| c.id == "permissions.unknown_key"));
299 }
300
301 #[test]
302 fn parse_yaml_invalid_level_fails() {
303 let yaml: Yaml = from_str("contents: full\n").unwrap();
304 let (_, checks) = parse_yaml_block(Some(&yaml));
305 assert!(checks.iter().any(|c| c.id == "permissions.unknown_level"));
306 }
307
308 #[test]
309 fn satisfies_when_level_matches() {
310 let mut set = PermissionSet::default();
311 set.entries
312 .insert("contents".to_owned(), PermissionLevel::Write);
313 let required = vec![RequiredPermission {
314 key: PermissionKey::Contents,
315 level: PermissionLevel::Write,
316 }];
317 assert!(satisfies(&set, &required));
318 assert!(missing(&set, &required).is_empty());
319 }
320
321 #[test]
322 fn missing_when_below_required_level() {
323 let mut set = PermissionSet::default();
324 set.entries
325 .insert("contents".to_owned(), PermissionLevel::Read);
326 let required = vec![RequiredPermission {
327 key: PermissionKey::Contents,
328 level: PermissionLevel::Write,
329 }];
330 assert_eq!(missing(&set, &required).len(), 1);
331 assert!(!satisfies(&set, &required));
332 }
333
334 #[test]
335 fn resolve_prefers_job_over_workflow() {
336 let mut wf = PermissionSet::default();
337 wf.entries
338 .insert("contents".to_owned(), PermissionLevel::Write);
339 let mut job = PermissionSet::default();
340 job.entries
341 .insert("contents".to_owned(), PermissionLevel::Read);
342 let resolution = resolve(Some(wf), Some(job), PermissionScope::Job);
343 assert!(matches!(resolution.source, PermissionSource::JobBlock));
344 assert_eq!(
345 resolution.effective.level(PermissionKey::Contents),
346 PermissionLevel::Read
347 );
348 }
349
350 #[test]
351 fn resolve_default_restricted_emits_warning() {
352 let resolution = resolve(None, None, PermissionScope::Job);
353 assert!(matches!(
354 resolution.source,
355 PermissionSource::DefaultRestricted
356 ));
357 assert!(
358 resolution
359 .checks
360 .iter()
361 .any(|c| c.id == "permissions.default_restricted")
362 );
363 }
364
365 #[test]
366 fn parse_json_permissions_object() {
367 let value: Value = serde_json::from_str(r#"{"contents":"write","issues":"read"}"#).unwrap();
368 let set = parse_json_permissions(&value).unwrap();
369 assert_eq!(set.level(PermissionKey::Contents), PermissionLevel::Write);
370 assert_eq!(set.level(PermissionKey::Issues), PermissionLevel::Read);
371 }
372
373 #[test]
374 fn parse_json_permissions_shorthand() {
375 let value: Value = serde_json::from_str(r#""write-all""#).unwrap();
376 let set = parse_json_permissions(&value).unwrap();
377 assert_eq!(set.level(PermissionKey::Contents), PermissionLevel::Write);
378 assert_eq!(set.level(PermissionKey::IdToken), PermissionLevel::None);
379 }
380
381 #[test]
382 fn parse_json_permissions_metadata_read_is_accepted_silently() {
383 let value: Value =
384 serde_json::from_str(r#"{"contents":"read","metadata":"read"}"#).unwrap();
385 let set = parse_json_permissions(&value).unwrap();
386 assert_eq!(
387 set.entries.get("metadata").copied(),
388 Some(PermissionLevel::Read)
389 );
390 assert!(
391 set.unknown_keys.iter().all(|k| k != "metadata"),
392 "metadata: read must not be treated as an unknown permission key (entries: {:?}, unknown: {:?})",
393 set.entries,
394 set.unknown_keys
395 );
396 }
397
398 #[test]
399 fn parse_json_permissions_metadata_write_is_rejected() {
400 let value: Value = serde_json::from_str(r#"{"metadata":"write"}"#).unwrap();
401 let err = parse_json_permissions(&value).unwrap_err();
402 let message = format!("{err:#}");
403 assert!(
404 message.contains("metadata") && message.contains("read"),
405 "expected explicit metadata read-only error, got: {message}"
406 );
407 }
408
409 #[test]
410 fn parse_json_permissions_metadata_none_is_rejected() {
411 let value: Value = serde_json::from_str(r#"{"metadata":"none"}"#).unwrap();
412 let err = parse_json_permissions(&value).unwrap_err();
413 let message = format!("{err:#}");
414 assert!(
415 message.contains("metadata") && message.contains("implicit"),
416 "expected explicit metadata-cannot-be-revoked error, got: {message}"
417 );
418 }
419
420 #[test]
421 fn parse_yaml_block_flags_metadata_as_not_configurable() {
422 let yaml: Yaml = from_str("metadata: read\n").unwrap();
423 let (set, checks) = parse_yaml_block(Some(&yaml));
424 let set = set.unwrap();
425 assert!(set.unknown_keys.iter().any(|k| k == "metadata"));
426 assert!(
427 checks
428 .iter()
429 .any(|c| c.id == "permissions.metadata_not_configurable"
430 && matches!(c.status, crate::model::CheckStatus::Fail)),
431 "expected Fail-level permissions.metadata_not_configurable check, got: {:?}",
432 checks
433 );
434 assert!(
437 checks.iter().all(|c| c.id != "permissions.unknown_key"),
438 "permissions.unknown_key must not fire for metadata; got: {:?}",
439 checks
440 );
441 }
442}