| 254 | 254 | | status: Option<String>, |
| 255 | 255 | | } |
| 256 | 256 | | |
| 257 | + | /// What a workspace's own token reaches: that workspace only, all of its |
| 258 | + | /// repositories or the ones chosen. Never none: without a reach a token |
| 259 | + | /// is not held to a workspace, and could open issues or comment on any |
| 260 | + | /// public repository as the workspace. |
| 261 | + | fn workspace_token_reach(slug: String, selection: RepositorySelection, repo_ids: Vec<String>) -> TokenReach { |
| 262 | + | let selection = if selection == RepositorySelection::Selected { selection } else { RepositorySelection::All }; |
| 263 | + | TokenReach { workspace: Some(slug), repositories: selection, repo_ids } |
| 264 | + | } |
| 265 | + | |
| 257 | 266 | | impl Identity { |
| 258 | 267 | | /// Cuts `user`, resolved from the token `token_id`, down to what the |
| 259 | 268 | | /// token reaches. `personal` is whether it is a person's token (not a |
| ⋯ |
| 266 | 275 | | // A workspace's own token: Write on its repositories, or Admin |
| 267 | 276 | | // when it holds Repositories: admin; the selected ones only |
| 268 | 277 | | // when it has a selection. |
| 269 | | − | let selected = facts.selection() == RepositorySelection::Selected; |
| 270 | | − | let repo_ids = if selected { self.token_repo_ids(token_id).await? } else { Vec::new() }; |
| 278 | + | let selection = facts.selection(); |
| 279 | + | let repo_ids = if selection == RepositorySelection::Selected { self.token_repo_ids(token_id).await? } else { Vec::new() }; |
| 271 | 280 | | let slug = user.username.clone(); |
| 272 | 281 | | if let Some(access) = user.token.as_deref_mut() { |
| 273 | 282 | | access.admin = facts.admin.is_some_and(|admin| admin >= 1.0); |
| 274 | | − | if selected { |
| 275 | | − | access.reach = Some(TokenReach { workspace: Some(slug), repositories: RepositorySelection::Selected, repo_ids }); |
| 276 | | − | } |
| 283 | + | access.reach = Some(workspace_token_reach(slug, selection, repo_ids)); |
| 277 | 284 | | } |
| 278 | 285 | | return Ok(()); |
| 279 | 286 | | } |
| ⋯ |
| 1266 | 1273 | | |
| 1267 | 1274 | | const DAY: u64 = 86_400_000; |
| 1268 | 1275 | | |
| 1276 | + | fn workspace_token(selection: RepositorySelection) -> g1t_contracts::scopes::TokenAccess { |
| 1277 | + | g1t_contracts::scopes::TokenAccess { |
| 1278 | + | scopes: Some(vec!["repo:read".into(), "code:write".into(), "issues:write".into()]), |
| 1279 | + | reach: Some(workspace_token_reach("acme".into(), selection, vec!["rep_1".into()])), |
| 1280 | + | ..Default::default() |
| 1281 | + | } |
| 1282 | + | } |
| 1283 | + | |
| 1284 | + | #[test] |
| 1285 | + | fn a_workspace_token_never_writes_outside_its_workspace() { |
| 1286 | + | use g1t_contracts::scopes::decide; |
| 1287 | + | for selection in [RepositorySelection::All, RepositorySelection::Selected] { |
| 1288 | + | let token = workspace_token(selection); |
| 1289 | + | let issue = |repo: &str| serde_json::json!({ "repo": repo, "title": "spam" }); |
| 1290 | + | let elsewhere = decide(&token, "create_issue", &issue("other/public")); |
| 1291 | + | assert!(!elsewhere.allowed, "{selection:?}"); |
| 1292 | + | assert_eq!(elsewhere.rule, "token:resource-owner"); |
| 1293 | + | assert!(decide(&token, "create_issue", &issue("acme/rocket")).allowed, "{selection:?}"); |
| 1294 | + | // Reading a public repository elsewhere is as anyone may. |
| 1295 | + | assert!(decide(&token, "get_repo", &serde_json::json!({ "repo": "other/public" })).allowed, "{selection:?}"); |
| 1296 | + | } |
| 1297 | + | } |
| 1298 | + | |
| 1299 | + | #[test] |
| 1300 | + | fn a_workspace_token_for_all_repositories_covers_its_own_only() { |
| 1301 | + | let all = workspace_token(RepositorySelection::All); |
| 1302 | + | assert!(all.covers_repo("rep_9", "acme")); |
| 1303 | + | assert!(all.covers_repo("rep_9", "ACME")); |
| 1304 | + | assert!(!all.covers_repo("rep_9", "other")); |
| 1305 | + | let selected = workspace_token(RepositorySelection::Selected); |
| 1306 | + | assert!(selected.covers_repo("rep_1", "acme")); |
| 1307 | + | assert!(!selected.covers_repo("rep_9", "acme")); |
| 1308 | + | } |
| 1309 | + | |
| 1269 | 1310 | | #[test] |
| 1270 | 1311 | | fn asks_credential_skips_the_rules_for_held_tokens_but_not_revoking() { |
| 1271 | 1312 | | let closed = TokenPolicy { allow_tokens_for_this_workspace: false, ..TokenPolicy::default() }; |