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
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 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 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}