Skip to main content

gha_github_service_proof/
permissions.rs

1use 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
14/// `metadata` is granted implicitly on every GitHub App installation token at
15/// `read` level. It is not configurable via the workflow `permissions:` block:
16/// declaring it there is a workflow-syntax error. ci-forge models the effective
17/// installation-token permissions and passes `metadata: read` through this
18/// JSON entry point; we accept it silently there and reject it from YAML.
19const 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                            // id-token follows GitHub: write must be explicit
38                            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                            // Implicit installation permission; ci-forge passes this
175                            // through. Accept silently and skip the unknown-key path.
176                            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        // id-token must remain default-none even under read-all
288        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        // and the generic unknown_key path must NOT fire for metadata — the
435        // dedicated check id replaces it so consumers can detect this case.
436        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}