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
14pub fn parse_yaml_block(value: Option<&Yaml>) -> (Option<PermissionSet>, Vec<Check>) {
15    let Some(value) = value else {
16        return (None, Vec::new());
17    };
18
19    let mut checks = Vec::new();
20    let mut set = PermissionSet::default();
21
22    match value {
23        Yaml::String(text) => {
24            let trimmed = text.trim();
25            match trimmed {
26                ALL_LEVELS_READ => {
27                    set.shorthand = Some(ALL_LEVELS_READ.to_owned());
28                    for key in PermissionKey::all() {
29                        if matches!(key, PermissionKey::IdToken) {
30                            // id-token follows GitHub: write must be explicit
31                            continue;
32                        }
33                        set.entries
34                            .insert(key.as_str().to_owned(), PermissionLevel::Read);
35                    }
36                }
37                ALL_LEVELS_WRITE => {
38                    set.shorthand = Some(ALL_LEVELS_WRITE.to_owned());
39                    for key in PermissionKey::all() {
40                        if matches!(key, PermissionKey::IdToken) {
41                            continue;
42                        }
43                        set.entries
44                            .insert(key.as_str().to_owned(), PermissionLevel::Write);
45                    }
46                }
47                "" => {
48                    checks.push(Check::fail(
49                        "permissions.empty_scalar",
50                        "permissions: cannot be an empty scalar",
51                    ));
52                }
53                other => {
54                    checks.push(Check::fail(
55                        "permissions.unknown_scalar",
56                        format!(
57                            "permissions scalar must be 'read-all' or 'write-all', got '{other}'"
58                        ),
59                    ));
60                }
61            }
62        }
63        Yaml::Mapping(map) => {
64            for (key_value, level_value) in map {
65                let Yaml::String(key) = key_value else {
66                    checks.push(Check::fail(
67                        "permissions.non_string_key",
68                        "permission keys must be strings",
69                    ));
70                    continue;
71                };
72                let Some(level_text) = scalar_text(level_value) else {
73                    checks.push(Check::fail(
74                        "permissions.non_string_level",
75                        format!("permission '{key}' must map to a string level"),
76                    ));
77                    continue;
78                };
79                let Some(level) = PermissionLevel::parse(&level_text) else {
80                    checks.push(Check::fail(
81                        "permissions.unknown_level",
82                        format!(
83                            "permission '{key}': level '{level_text}' is not one of none|read|write"
84                        ),
85                    ));
86                    continue;
87                };
88                if PermissionKey::parse(key).is_none() {
89                    set.unknown_keys.push(key.clone());
90                    checks.push(Check::warn(
91                        "permissions.unknown_key",
92                        format!("permission key '{key}' is not a recognized GitHub permission"),
93                    ));
94                }
95                set.entries.insert(key.clone(), level);
96            }
97        }
98        _ => {
99            checks.push(Check::fail(
100                "permissions.invalid_shape",
101                "permissions must be a scalar (read-all|write-all) or a mapping",
102            ));
103        }
104    }
105
106    (Some(set), checks)
107}
108
109fn scalar_text(value: &Yaml) -> Option<String> {
110    match value {
111        Yaml::String(text) => Some(text.clone()),
112        Yaml::Bool(b) => Some(b.to_string()),
113        Yaml::Number(n) => Some(n.to_string()),
114        _ => None,
115    }
116}
117
118pub fn parse_json_permissions(value: &Value) -> Result<PermissionSet> {
119    let mut set = PermissionSet::default();
120    match value {
121        Value::Null => Ok(set),
122        Value::String(text) => match text.as_str() {
123            ALL_LEVELS_READ => {
124                set.shorthand = Some(ALL_LEVELS_READ.to_owned());
125                for key in PermissionKey::all() {
126                    if matches!(key, PermissionKey::IdToken) {
127                        continue;
128                    }
129                    set.entries
130                        .insert(key.as_str().to_owned(), PermissionLevel::Read);
131                }
132                Ok(set)
133            }
134            ALL_LEVELS_WRITE => {
135                set.shorthand = Some(ALL_LEVELS_WRITE.to_owned());
136                for key in PermissionKey::all() {
137                    if matches!(key, PermissionKey::IdToken) {
138                        continue;
139                    }
140                    set.entries
141                        .insert(key.as_str().to_owned(), PermissionLevel::Write);
142                }
143                Ok(set)
144            }
145            other => bail!("permissions scalar must be 'read-all' or 'write-all', got '{other}'"),
146        },
147        Value::Object(map) => {
148            for (key, level) in map {
149                let Value::String(level_text) = level else {
150                    bail!("permission '{key}' must map to a string level");
151                };
152                let Some(parsed) = PermissionLevel::parse(level_text) else {
153                    bail!("permission '{key}': level '{level_text}' is not one of none|read|write");
154                };
155                if PermissionKey::parse(key).is_none() {
156                    set.unknown_keys.push(key.clone());
157                }
158                set.entries.insert(key.clone(), parsed);
159            }
160            Ok(set)
161        }
162        _ => bail!("permissions must be null, a string shorthand, or an object mapping"),
163    }
164}
165
166pub fn resolve(
167    workflow_permissions: Option<PermissionSet>,
168    job_permissions: Option<PermissionSet>,
169    scope: PermissionScope,
170) -> PermissionResolution {
171    let (effective, source) = match (&job_permissions, &workflow_permissions, scope) {
172        (Some(job), _, PermissionScope::Job) => (job.clone(), PermissionSource::JobBlock),
173        (None, Some(wf), PermissionScope::Job) => (wf.clone(), PermissionSource::WorkflowBlock),
174        (_, Some(wf), PermissionScope::Workflow) => (wf.clone(), PermissionSource::WorkflowBlock),
175        _ => (default_restricted(), PermissionSource::DefaultRestricted),
176    };
177
178    let mut checks = Vec::new();
179    if matches!(source, PermissionSource::DefaultRestricted) {
180        checks.push(Check::warn(
181            "permissions.default_restricted",
182            "no permissions block found at workflow or job level; assuming restricted defaults (contents: read). Set an explicit permissions block to pin behavior.",
183        ));
184    }
185
186    PermissionResolution {
187        scope,
188        workflow_permissions,
189        job_permissions,
190        effective,
191        source,
192        checks,
193    }
194}
195
196pub fn default_restricted() -> PermissionSet {
197    let mut set = PermissionSet::default();
198    set.entries.insert(
199        PermissionKey::Contents.as_str().to_owned(),
200        PermissionLevel::Read,
201    );
202    set
203}
204
205pub fn missing(set: &PermissionSet, required: &[RequiredPermission]) -> Vec<RequiredPermission> {
206    required
207        .iter()
208        .filter(|req| !set.level(req.key).satisfies(req.level))
209        .cloned()
210        .collect()
211}
212
213pub fn satisfies(set: &PermissionSet, required: &[RequiredPermission]) -> bool {
214    missing(set, required).is_empty()
215}
216
217pub fn effective_map(set: &PermissionSet) -> BTreeMap<String, PermissionLevel> {
218    set.entries.clone()
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use serde_yaml::from_str;
225
226    #[test]
227    fn parse_yaml_mapping_block() {
228        let yaml: Yaml = from_str("contents: write\nissues: read\n").unwrap();
229        let (set, checks) = parse_yaml_block(Some(&yaml));
230        let set = set.unwrap();
231        assert_eq!(set.level(PermissionKey::Contents), PermissionLevel::Write);
232        assert_eq!(set.level(PermissionKey::Issues), PermissionLevel::Read);
233        assert_eq!(
234            set.level(PermissionKey::PullRequests),
235            PermissionLevel::None
236        );
237        assert!(
238            checks
239                .iter()
240                .all(|c| !matches!(c.status, crate::model::CheckStatus::Fail))
241        );
242    }
243
244    #[test]
245    fn parse_yaml_read_all_shortcut() {
246        let yaml: Yaml = from_str("read-all").unwrap();
247        let (set, _) = parse_yaml_block(Some(&yaml));
248        let set = set.unwrap();
249        assert_eq!(set.level(PermissionKey::Contents), PermissionLevel::Read);
250        assert_eq!(set.level(PermissionKey::Issues), PermissionLevel::Read);
251        // id-token must remain default-none even under read-all
252        assert_eq!(set.level(PermissionKey::IdToken), PermissionLevel::None);
253        assert_eq!(set.shorthand.as_deref(), Some("read-all"));
254    }
255
256    #[test]
257    fn parse_yaml_unknown_key_warns_but_keeps_value() {
258        let yaml: Yaml = from_str("bogus: write\n").unwrap();
259        let (set, checks) = parse_yaml_block(Some(&yaml));
260        let set = set.unwrap();
261        assert!(set.unknown_keys.iter().any(|k| k == "bogus"));
262        assert!(checks.iter().any(|c| c.id == "permissions.unknown_key"));
263    }
264
265    #[test]
266    fn parse_yaml_invalid_level_fails() {
267        let yaml: Yaml = from_str("contents: full\n").unwrap();
268        let (_, checks) = parse_yaml_block(Some(&yaml));
269        assert!(checks.iter().any(|c| c.id == "permissions.unknown_level"));
270    }
271
272    #[test]
273    fn satisfies_when_level_matches() {
274        let mut set = PermissionSet::default();
275        set.entries
276            .insert("contents".to_owned(), PermissionLevel::Write);
277        let required = vec![RequiredPermission {
278            key: PermissionKey::Contents,
279            level: PermissionLevel::Write,
280        }];
281        assert!(satisfies(&set, &required));
282        assert!(missing(&set, &required).is_empty());
283    }
284
285    #[test]
286    fn missing_when_below_required_level() {
287        let mut set = PermissionSet::default();
288        set.entries
289            .insert("contents".to_owned(), PermissionLevel::Read);
290        let required = vec![RequiredPermission {
291            key: PermissionKey::Contents,
292            level: PermissionLevel::Write,
293        }];
294        assert_eq!(missing(&set, &required).len(), 1);
295        assert!(!satisfies(&set, &required));
296    }
297
298    #[test]
299    fn resolve_prefers_job_over_workflow() {
300        let mut wf = PermissionSet::default();
301        wf.entries
302            .insert("contents".to_owned(), PermissionLevel::Write);
303        let mut job = PermissionSet::default();
304        job.entries
305            .insert("contents".to_owned(), PermissionLevel::Read);
306        let resolution = resolve(Some(wf), Some(job), PermissionScope::Job);
307        assert!(matches!(resolution.source, PermissionSource::JobBlock));
308        assert_eq!(
309            resolution.effective.level(PermissionKey::Contents),
310            PermissionLevel::Read
311        );
312    }
313
314    #[test]
315    fn resolve_default_restricted_emits_warning() {
316        let resolution = resolve(None, None, PermissionScope::Job);
317        assert!(matches!(
318            resolution.source,
319            PermissionSource::DefaultRestricted
320        ));
321        assert!(
322            resolution
323                .checks
324                .iter()
325                .any(|c| c.id == "permissions.default_restricted")
326        );
327    }
328
329    #[test]
330    fn parse_json_permissions_object() {
331        let value: Value = serde_json::from_str(r#"{"contents":"write","issues":"read"}"#).unwrap();
332        let set = parse_json_permissions(&value).unwrap();
333        assert_eq!(set.level(PermissionKey::Contents), PermissionLevel::Write);
334        assert_eq!(set.level(PermissionKey::Issues), PermissionLevel::Read);
335    }
336
337    #[test]
338    fn parse_json_permissions_shorthand() {
339        let value: Value = serde_json::from_str(r#""write-all""#).unwrap();
340        let set = parse_json_permissions(&value).unwrap();
341        assert_eq!(set.level(PermissionKey::Contents), PermissionLevel::Write);
342        assert_eq!(set.level(PermissionKey::IdToken), PermissionLevel::None);
343    }
344}