Rename the issues scopes to work:read and work:write
Tokens' scopes follow work items: the issues resource is now work (shown as Work), and the operation table names the work item operations, with comments on pull requests needing pull_requests:write. Identity rewrites every stored scope list and run token's operations so tokens keep the access they had.
- G1T-Session:
- ses_01m566353xebrscb7pf471bnn1
9 files+238−1790/9 viewed
| 56 | 56 | /// and pushing that to a branch of its own. Not an agent. | |
| 57 | 57 | Bump, | |
| 58 | 58 | /// A session g1t started on an agent's harness (Claude Managed Agents) | |
| 59 | − | /// for the person who asked, on one issue or pull request. It works | |
| 59 | + | /// for the person who asked, on one work item or pull request. It works | |
| 60 | 60 | /// through the API, MCP and the CLI with what [`SESSION_SCOPES`] allow, | |
| 61 | 61 | /// in its repository only, and pushes to that repository and its pull | |
| 62 | 62 | /// requests' working copies. What it writes (comments, reviews, closes, | |
| 63 | − | /// the issues and pull requests it opens) is g1t's, for that person; | |
| 63 | + | /// the work items and pull requests it opens) is g1t's, for that person; | |
| 64 | 64 | /// what it may do is that person's role there, never beyond | |
| 65 | 65 | /// [`AGENT_CEILING`]. | |
| 66 | 66 | Session, | |
| ⋯ | |||
| 289 | 289 | "list_check_names", | |
| 290 | 290 | "get_merge_queue", | |
| 291 | 291 | "get_pull_request_merge_options", | |
| 292 | − | "list_issues", | |
| 293 | − | "get_issue", | |
| 292 | + | "list_work_items", | |
| 293 | + | "list_repo_work_items", | |
| 294 | + | "get_work_item", | |
| 295 | + | "list_work_item_links", | |
| 296 | + | "resolve_ref", | |
| 294 | 297 | "list_labels", | |
| 295 | − | "list_issue_labels", | |
| 298 | + | "list_work_item_labels", | |
| 299 | + | "list_work_item_decisions", | |
| 296 | 300 | "list_milestones", | |
| 297 | 301 | "get_milestone", | |
| 298 | 302 | "list_pull_requests", | |
| ⋯ | |||
| 398 | 402 | "disable_pull_request_auto_merge", | |
| 399 | 403 | "delete_pull_request_head_branch", | |
| 400 | 404 | "restore_pull_request_head_branch", | |
| 401 | − | "import_issue", | |
| 405 | + | "import_work_items", | |
| 402 | 406 | "list_integrations", | |
| 403 | 407 | "connect_integration", | |
| 404 | 408 | "update_integration", | |
| ⋯ | |||
| 537 | 541 | } | |
| 538 | 542 | ||
| 539 | 543 | /// The scopes of a session's credential ([`RunCredentialKind::Session`]): | |
| 540 | − | /// what working on an issue needs, named as an access token's are. | |
| 544 | + | /// what working on a work item needs, named as an access token's are. | |
| 541 | 545 | pub const SESSION_SCOPES: [&str; 8] = [ | |
| 542 | 546 | "repo:read", | |
| 543 | 547 | "code:write", | |
| 544 | − | "issues:write", | |
| 548 | + | "work:write", | |
| 545 | 549 | "pull_requests:write", | |
| 546 | 550 | "checks:read", | |
| 547 | 551 | "workflows:read", | |
| ⋯ | |||
| 641 | 645 | ||
| 642 | 646 | /// Whether `user`, resolved from a run credential with `scope`, may use | |
| 643 | 647 | /// `operation`. `repo` is the repository the call names, if any, and | |
| 644 | − | /// `needs_repo` whether the operation is about one; `number` the issue or | |
| 648 | + | /// `needs_repo` whether the operation is about one; `number` the work item or | |
| 645 | 649 | /// pull request it names. | |
| 646 | 650 | pub fn decide_operation( | |
| 647 | 651 | user: &User, | |
| ⋯ | |||
| 956 | 960 | _ => vec![], | |
| 957 | 961 | }, | |
| 958 | 962 | CredentialUse::Tools => match kind { | |
| 959 | − | K::Implement | K::Revise | K::Answer => vec!["get_issue", "get_pull_request", "create_issue", "add_comment", "remember"], | |
| 960 | − | K::Review => vec!["get_issue", "get_pull_request", "add_comment", "review_pull_request"], | |
| 961 | − | K::Plan => vec!["get_issue", "get_pull_request", "create_issue"], | |
| 962 | − | K::Update => vec!["get_issue", "get_pull_request"], | |
| 963 | + | K::Implement | K::Revise | K::Answer => vec!["get_work_item", "get_pull_request", "open_work_item", "add_comment", "remember"], | |
| 964 | + | K::Review => vec!["get_work_item", "get_pull_request", "add_comment", "review_pull_request"], | |
| 965 | + | K::Plan => vec!["get_work_item", "get_pull_request", "open_work_item"], | |
| 966 | + | K::Update => vec!["get_work_item", "get_pull_request"], | |
| 963 | 967 | _ => vec![], | |
| 964 | 968 | }, | |
| 965 | 969 | }; | |
| ⋯ | |||
| 1042 | 1046 | /// may. | |
| 1043 | 1047 | #[test] | |
| 1044 | 1048 | fn a_stored_token_uses_only_what_it_lists() { | |
| 1045 | − | assert!(op(K::Implement, Tools, "create_issue").allowed); | |
| 1049 | + | assert!(op(K::Implement, Tools, "open_work_item").allowed); | |
| 1046 | 1050 | assert!(op(K::Review, Tools, "review_pull_request").allowed); | |
| 1047 | − | assert!(!op(K::Review, Tools, "create_issue").allowed); | |
| 1051 | + | assert!(!op(K::Review, Tools, "open_work_item").allowed); | |
| 1048 | 1052 | assert!(!op(K::Plan, Tools, "add_comment").allowed); | |
| 1049 | − | assert!(!op(K::Checks, Tools, "get_issue").allowed); | |
| 1053 | + | assert!(!op(K::Checks, Tools, "get_work_item").allowed); | |
| 1050 | 1054 | for operation in ["record_session", "merge_pull_request", "update_repo_settings", "create_webhook", "set_actions_secret"] { | |
| 1051 | 1055 | for kind in [K::Implement, K::Revise, K::Answer, K::Review, K::Plan, K::Checks] { | |
| 1052 | 1056 | assert!(!op(kind, Tools, operation).allowed, "{operation} by a {} run's tools", kind.as_str()); | |
| ⋯ | |||
| 1060 | 1064 | assert!(op(K::Implement, Runner, "mark_pull_request_ready").allowed); | |
| 1061 | 1065 | assert!(op(K::Revise, Runner, "record_session").allowed); | |
| 1062 | 1066 | assert!(!op(K::Revise, Runner, "mark_pull_request_ready").allowed); | |
| 1063 | − | assert!(!op(K::Implement, Runner, "create_issue").allowed); | |
| 1067 | + | assert!(!op(K::Implement, Runner, "open_work_item").allowed); | |
| 1064 | 1068 | assert!(!op(K::Review, Runner, "record_session").allowed); | |
| 1065 | − | assert!(!op(K::Checks, Runner, "get_issue").allowed); | |
| 1069 | + | assert!(!op(K::Checks, Runner, "get_work_item").allowed); | |
| 1066 | 1070 | } | |
| 1067 | 1071 | ||
| 1068 | 1072 | #[test] | |
| ⋯ | |||
| 1098 | 1102 | let decision = decide_operation( | |
| 1099 | 1103 | &user, | |
| 1100 | 1104 | &scope, | |
| 1101 | − | "create_issue", | |
| 1105 | + | "open_work_item", | |
| 1102 | 1106 | Some(&path("acme", "other")), | |
| 1103 | 1107 | true, | |
| 1104 | 1108 | None, | |
| 1105 | 1109 | ); | |
| 1106 | 1110 | assert!(!decision.allowed); | |
| 1107 | 1111 | assert_eq!(decision.rule, "scope:repository"); | |
| 1108 | − | let decision = decide_operation(&user, &scope, "create_issue", None, true, None); | |
| 1112 | + | let decision = decide_operation(&user, &scope, "open_work_item", None, true, None); | |
| 1109 | 1113 | assert_eq!(decision.rule, "scope:repository"); | |
| 1110 | 1114 | // The repository's name is matched without regard to case. | |
| 1111 | 1115 | let decision = decide_operation( | |
| 1112 | 1116 | &user, | |
| 1113 | 1117 | &scope, | |
| 1114 | − | "create_issue", | |
| 1118 | + | "open_work_item", | |
| 1115 | 1119 | Some(&path("Acme", "Rocket")), | |
| 1116 | 1120 | true, | |
| 1117 | 1121 | None, | |
| ⋯ | |||
| 1128 | 1132 | let decision = decide_operation( | |
| 1129 | 1133 | &user, | |
| 1130 | 1134 | &scope, | |
| 1131 | − | "get_issue", | |
| 1135 | + | "get_work_item", | |
| 1132 | 1136 | Some(&path("acme", "rocket")), | |
| 1133 | 1137 | true, | |
| 1134 | 1138 | Some(1), | |
| ⋯ | |||
| 1190 | 1194 | assert_eq!(permission(Some(&agent_user), rocket), Some(RepoRole::Write)); | |
| 1191 | 1195 | assert_eq!(permission(Some(&agent_user), other), None); | |
| 1192 | 1196 | assert_eq!(permission(Some(&agent_user), elsewhere), None); | |
| 1193 | − | let decision = decide_operation(&agent_user, &tools, "get_issue", Some(&path("acme", "rocket")), true, Some(1)); | |
| 1197 | + | let decision = decide_operation(&agent_user, &tools, "get_work_item", Some(&path("acme", "rocket")), true, Some(1)); | |
| 1194 | 1198 | assert!(decision.allowed, "{}", decision.reason.unwrap_or_default()); | |
| 1195 | 1199 | // The person, downstream of a runner's credential, carries the same. | |
| 1196 | 1200 | let person = as_person(&agent_user).expect("a runner acts as the person"); | |
| ⋯ | |||
| 1239 | 1243 | // A tools token made before run credentials never reaches git. | |
| 1240 | 1244 | let old = AgentScope { | |
| 1241 | 1245 | repo: upstream.clone(), | |
| 1242 | − | operations: vec!["get_issue".to_owned()], | |
| 1246 | + | operations: vec!["get_work_item".to_owned()], | |
| 1243 | 1247 | run: None, | |
| 1244 | 1248 | }; | |
| 1245 | 1249 | assert_eq!(decide_git(&old, &upstream, false).rule, "git:not-a-run"); | |
| ⋯ | |||
| 1302 | 1306 | #[test] | |
| 1303 | 1307 | fn scopes_without_a_run_still_parse() { | |
| 1304 | 1308 | let old: AgentScope = serde_json::from_str( | |
| 1305 | − | r#"{"repo":{"namespace":"acme","name":"rocket"},"operations":["get_issue"]}"#, | |
| 1309 | + | r#"{"repo":{"namespace":"acme","name":"rocket"},"operations":["get_work_item"]}"#, | |
| 1306 | 1310 | ) | |
| 1307 | 1311 | .unwrap(); | |
| 1308 | 1312 | assert!(old.run.is_none()); | |
| ⋯ | |||
| 1338 | 1342 | } | |
| 1339 | 1343 | } | |
| 1340 | 1344 | ||
| 1341 | − | /// A session works on its issue through the API and MCP with what its | |
| 1345 | + | /// A session works on its work item through the API and MCP with what its | |
| 1342 | 1346 | /// scopes allow, in its repository, and never with what no run may. | |
| 1343 | 1347 | #[test] | |
| 1344 | 1348 | fn a_session_does_what_its_scopes_allow_in_its_repository() { | |
| ⋯ | |||
| 1349 | 1353 | for operation in [ | |
| 1350 | 1354 | "whoami", | |
| 1351 | 1355 | "get_work", | |
| 1352 | − | "claim_issue", | |
| 1356 | + | "claim_work_item", | |
| 1353 | 1357 | "add_comment", | |
| 1354 | 1358 | // A screenshot of its preview, for a pull request or a comment. | |
| 1355 | 1359 | "upload_attachment", | |
| 1356 | − | "close_issue", | |
| 1360 | + | "close_work_item", | |
| 1357 | 1361 | "create_pull_request", | |
| 1358 | 1362 | "review_pull_request", | |
| 1359 | 1363 | "mark_pull_request_ready", | |
| ⋯ | |||
| 1376 | 1380 | } | |
| 1377 | 1381 | use crate::scopes::Scope; | |
| 1378 | 1382 | let scopes = run_scopes(K::Session, Runner); | |
| 1379 | − | assert!(scopes.contains(&Scope::CodeWrite) && scopes.contains(&Scope::IssuesWrite)); | |
| 1383 | + | assert!(scopes.contains(&Scope::CodeWrite) && scopes.contains(&Scope::WorkWrite)); | |
| 1380 | 1384 | assert!(scopes.iter().all(|scope| !scope.dangerous())); | |
| 1381 | 1385 | } | |
| 1382 | 1386 | ||
| 243 | 243 | let token = access("dk_1", "CI", "acme/web", true); | |
| 244 | 244 | assert_eq!(token.deploy_key.as_deref(), Some("dk_1")); | |
| 245 | 245 | assert!(!token.admin); | |
| 246 | − | assert!(!token.allows(Scope::IssuesWrite)); | |
| 246 | + | assert!(!token.allows(Scope::WorkWrite)); | |
| 247 | 247 | assert!(!token.allows(Scope::AccessAdmin)); | |
| 248 | 248 | } | |
| 249 | 249 |
| 8 | 8 | //! (see [`crate::access`]) and the token's scopes. | |
| 9 | 9 | //! | |
| 10 | 10 | //! Each scope is a resource and a level, written `resource:level`, such as | |
| 11 | − | //! `issues:write`. A higher level of a resource includes the lower ones: | |
| 11 | + | //! `work:write`. A higher level of a resource includes the lower ones: | |
| 12 | 12 | //! `repo:admin` includes `repo:write`, which includes `repo:create`, which | |
| 13 | 13 | //! includes `repo:read`. | |
| 14 | 14 | //! | |
| ⋯ | |||
| 35 | 35 | Code, | |
| 36 | 36 | Security, | |
| 37 | 37 | Packages, | |
| 38 | − | Issues, | |
| 38 | + | Work, | |
| 39 | 39 | PullRequests, | |
| 40 | 40 | Agents, | |
| 41 | 41 | Workflows, | |
| ⋯ | |||
| 64 | 64 | Resource::Code, | |
| 65 | 65 | Resource::Security, | |
| 66 | 66 | Resource::Packages, | |
| 67 | − | Resource::Issues, | |
| 67 | + | Resource::Work, | |
| 68 | 68 | Resource::PullRequests, | |
| 69 | 69 | Resource::Agents, | |
| 70 | 70 | Resource::Workflows, | |
| ⋯ | |||
| 97 | 97 | Resource::Code => "code", | |
| 98 | 98 | Resource::Security => "security", | |
| 99 | 99 | Resource::Packages => "packages", | |
| 100 | − | Resource::Issues => "issues", | |
| 100 | + | Resource::Work => "work", | |
| 101 | 101 | Resource::PullRequests => "pull_requests", | |
| 102 | 102 | Resource::Agents => "agents", | |
| 103 | 103 | Resource::Workflows => "workflows", | |
| ⋯ | |||
| 127 | 127 | Resource::Code => "Code", | |
| 128 | 128 | Resource::Security => "Security", | |
| 129 | 129 | Resource::Packages => "Packages", | |
| 130 | − | Resource::Issues => "Issues", | |
| 130 | + | Resource::Work => "Work", | |
| 131 | 131 | Resource::PullRequests => "Pull requests", | |
| 132 | 132 | Resource::Agents => "g1t agents", | |
| 133 | 133 | Resource::Workflows => "Workflows", | |
| ⋯ | |||
| 212 | 212 | PackagesRead, | |
| 213 | 213 | PackagesWrite, | |
| 214 | 214 | PackagesDelete, | |
| 215 | − | IssuesRead, | |
| 216 | − | IssuesWrite, | |
| 215 | + | WorkRead, | |
| 216 | + | WorkWrite, | |
| 217 | 217 | PullRequestsRead, | |
| 218 | 218 | PullRequestsWrite, | |
| 219 | 219 | /// Retired with g1t's own agents ([`Resource::retired`]): parses, so | |
| ⋯ | |||
| 273 | 273 | Scope::PackagesRead, | |
| 274 | 274 | Scope::PackagesWrite, | |
| 275 | 275 | Scope::PackagesDelete, | |
| 276 | − | Scope::IssuesRead, | |
| 277 | − | Scope::IssuesWrite, | |
| 276 | + | Scope::WorkRead, | |
| 277 | + | Scope::WorkWrite, | |
| 278 | 278 | Scope::PullRequestsRead, | |
| 279 | 279 | Scope::PullRequestsWrite, | |
| 280 | 280 | Scope::AgentsRun, | |
| ⋯ | |||
| 327 | 327 | Scope::PackagesRead => "packages:read", | |
| 328 | 328 | Scope::PackagesWrite => "packages:write", | |
| 329 | 329 | Scope::PackagesDelete => "packages:delete", | |
| 330 | − | Scope::IssuesRead => "issues:read", | |
| 331 | − | Scope::IssuesWrite => "issues:write", | |
| 330 | + | Scope::WorkRead => "work:read", | |
| 331 | + | Scope::WorkWrite => "work:write", | |
| 332 | 332 | Scope::PullRequestsRead => "pull_requests:read", | |
| 333 | 333 | Scope::PullRequestsWrite => "pull_requests:write", | |
| 334 | 334 | Scope::AgentsRun => "agents:run", | |
| ⋯ | |||
| 408 | 408 | /// What it lets a token do, in plain words. | |
| 409 | 409 | pub fn describe(self) -> &'static str { | |
| 410 | 410 | match self { | |
| 411 | − | Scope::RepoRead => "See repositories, their settings, labels, timelines, releases, languages, contributors and security alerts, and search", | |
| 411 | + | Scope::RepoRead => "See repositories, their settings, timelines, releases, languages, contributors and security alerts, and search", | |
| 412 | 412 | Scope::RepoCreate => "Create repositories, and nothing that changes or deletes one", | |
| 413 | 413 | Scope::RepoWrite => "Create repositories, create, rename and delete branches, change how pull requests merge and publish releases", | |
| 414 | 414 | Scope::RepoAdmin => "Rename, archive, transfer, delete or change who can see a repository, change its rulesets, and dismiss security alerts", | |
| ⋯ | |||
| 419 | 419 | Scope::PackagesRead => "Pull container images and install private packages", | |
| 420 | 420 | Scope::PackagesWrite => "Push container images and publish packages", | |
| 421 | 421 | Scope::PackagesDelete => "Delete and restore packages and their versions", | |
| 422 | − | Scope::IssuesRead => "Read issues and comments", | |
| 423 | − | Scope::IssuesWrite => "Open, edit, close and comment on issues", | |
| 422 | + | Scope::WorkRead => "Read work items, their comments, labels, milestones and claims", | |
| 423 | + | Scope::WorkWrite => "Open, edit, link, close and comment on work items, claim them, and manage labels and milestones", | |
| 424 | 424 | Scope::PullRequestsRead => "Read pull requests, their changes, sessions and merge queues", | |
| 425 | − | Scope::PullRequestsWrite => "Open, review, close and merge pull requests", | |
| 425 | + | Scope::PullRequestsWrite => "Open, review, comment on, close and merge pull requests", | |
| 426 | 426 | Scope::AgentsRun => "Retired: put g1t's own agents to work. Grants nothing now", | |
| 427 | 427 | Scope::WorkflowsRead => "Read workflows, runs and logs", | |
| 428 | 428 | Scope::WorkflowsWrite => "Run, cancel, rerun and turn workflows on or off", | |
| ⋯ | |||
| 556 | 556 | // --- Permissions -------------------------------------------------------------- | |
| 557 | 557 | // | |
| 558 | 558 | // A token's permissions are its scopes read per resource: each resource | |
| 559 | − | // at none or one level (`{"issues": "write", "repo": "read"}`). A level | |
| 559 | + | // at none or one level (`{"work": "write", "repo": "read"}`). A level | |
| 560 | 560 | // includes the ones below it, so the highest scope held of each resource | |
| 561 | 561 | // says everything; that is what a token stores. Personal tokens and a | |
| 562 | 562 | // workspace's own tokens are made, shown and checked this way alike. | |
| ⋯ | |||
| 588 | 588 | .collect() | |
| 589 | 589 | } | |
| 590 | 590 | ||
| 591 | − | /// Permissions as asked for (`{"issues": "write"}`, `none` or empty left | |
| 591 | + | /// Permissions as asked for (`{"work": "write"}`, `none` or empty left | |
| 592 | 592 | /// out) into the scopes a token stores, or why they cannot be. `personal` | |
| 593 | 593 | /// is whether the token is a person's: only theirs may hold account ones. | |
| 594 | 594 | pub fn resolve_permissions(asked: &std::collections::BTreeMap<String, String>, personal: bool) -> Result<Vec<Scope>, String> { | |
| ⋯ | |||
| 670 | 670 | scopes.extend([ | |
| 671 | 671 | Scope::RepoCreate, | |
| 672 | 672 | Scope::CodeWrite, | |
| 673 | − | Scope::IssuesWrite, | |
| 673 | + | Scope::WorkWrite, | |
| 674 | 674 | Scope::PullRequestsWrite, | |
| 675 | 675 | Scope::NotificationsWrite, | |
| 676 | 676 | ]); | |
| ⋯ | |||
| 1030 | 1030 | ("get_repo", Scope::RepoRead), | |
| 1031 | 1031 | ("list_forks", Scope::RepoRead), | |
| 1032 | 1032 | // Comments on commits are read with the repository, and written as | |
| 1033 | − | // comments on issues are. | |
| 1033 | + | // comments on work items are. | |
| 1034 | 1034 | ("list_repo_commit_comments", Scope::RepoRead), | |
| 1035 | 1035 | ("list_commit_comments", Scope::RepoRead), | |
| 1036 | 1036 | // A repository's files, as clone and fetch need. | |
| ⋯ | |||
| 1060 | 1060 | ("create_release", Scope::RepoWrite), | |
| 1061 | 1061 | ("update_release", Scope::RepoWrite), | |
| 1062 | 1062 | ("delete_release", Scope::RepoWrite), | |
| 1063 | − | ("list_labels", Scope::RepoRead), | |
| 1064 | − | ("list_milestones", Scope::RepoRead), | |
| 1065 | − | ("get_milestone", Scope::RepoRead), | |
| 1066 | − | ("create_label", Scope::IssuesWrite), | |
| 1067 | − | ("update_label", Scope::IssuesWrite), | |
| 1068 | − | ("delete_label", Scope::IssuesWrite), | |
| 1069 | − | ("add_default_labels", Scope::IssuesWrite), | |
| 1070 | − | ("create_milestone", Scope::IssuesWrite), | |
| 1071 | − | ("update_milestone", Scope::IssuesWrite), | |
| 1072 | − | ("delete_milestone", Scope::IssuesWrite), | |
| 1063 | + | // A workspace's labels and milestones, which its work items and pull | |
| 1064 | + | // requests carry. | |
| 1065 | + | ("list_labels", Scope::WorkRead), | |
| 1066 | + | ("list_milestones", Scope::WorkRead), | |
| 1067 | + | ("get_milestone", Scope::WorkRead), | |
| 1068 | + | ("create_label", Scope::WorkWrite), | |
| 1069 | + | ("update_label", Scope::WorkWrite), | |
| 1070 | + | ("delete_label", Scope::WorkWrite), | |
| 1071 | + | ("add_default_labels", Scope::WorkWrite), | |
| 1072 | + | ("create_milestone", Scope::WorkWrite), | |
| 1073 | + | ("update_milestone", Scope::WorkWrite), | |
| 1074 | + | ("delete_milestone", Scope::WorkWrite), | |
| 1073 | 1075 | ("get_repo_settings", Scope::RepoRead), | |
| 1074 | 1076 | ("list_check_names", Scope::RepoRead), | |
| 1075 | 1077 | ("list_deleted_repos", Scope::RepoRead), | |
| ⋯ | |||
| 1150 | 1152 | ("set_security_advisory_state", Scope::SecurityWrite), | |
| 1151 | 1153 | ("report_vulnerability", Scope::SecurityWrite), | |
| 1152 | 1154 | ("review_vulnerability_report", Scope::SecurityWrite), | |
| 1153 | − | // Issues. | |
| 1154 | − | ("list_issues", Scope::IssuesRead), | |
| 1155 | − | ("get_issue", Scope::IssuesRead), | |
| 1156 | − | ("create_issue", Scope::IssuesWrite), | |
| 1157 | − | ("update_issue", Scope::IssuesWrite), | |
| 1158 | − | ("list_issue_labels", Scope::IssuesRead), | |
| 1159 | − | ("add_issue_labels", Scope::IssuesWrite), | |
| 1160 | − | ("set_issue_labels", Scope::IssuesWrite), | |
| 1161 | − | ("remove_issue_labels", Scope::IssuesWrite), | |
| 1162 | − | ("close_issue", Scope::IssuesWrite), | |
| 1163 | − | ("reopen_issue", Scope::IssuesWrite), | |
| 1164 | − | ("add_comment", Scope::IssuesWrite), | |
| 1155 | + | // Work items: a workspace's tracked work, its comments and labels. | |
| 1156 | + | ("list_work_items", Scope::WorkRead), | |
| 1157 | + | ("list_repo_work_items", Scope::WorkRead), | |
| 1158 | + | ("get_work_item", Scope::WorkRead), | |
| 1159 | + | ("list_work_item_links", Scope::WorkRead), | |
| 1160 | + | ("resolve_ref", Scope::WorkRead), | |
| 1161 | + | ("open_work_item", Scope::WorkWrite), | |
| 1162 | + | ("update_work_item", Scope::WorkWrite), | |
| 1163 | + | ("close_work_item", Scope::WorkWrite), | |
| 1164 | + | ("reopen_work_item", Scope::WorkWrite), | |
| 1165 | + | ("link_work_item", Scope::WorkWrite), | |
| 1166 | + | ("unlink_work_item", Scope::WorkWrite), | |
| 1167 | + | ("list_work_item_labels", Scope::WorkRead), | |
| 1168 | + | ("add_work_item_labels", Scope::WorkWrite), | |
| 1169 | + | ("set_work_item_labels", Scope::WorkWrite), | |
| 1170 | + | ("remove_work_item_labels", Scope::WorkWrite), | |
| 1171 | + | ("list_work_item_decisions", Scope::WorkRead), | |
| 1172 | + | ("add_work_item_comment", Scope::WorkWrite), | |
| 1173 | + | ("edit_work_item_comment", Scope::WorkWrite), | |
| 1174 | + | ("delete_work_item_comment", Scope::WorkWrite), | |
| 1175 | + | ("react_to_work_item", Scope::WorkWrite), | |
| 1165 | 1176 | // An image for a description or a comment: whoever may comment. | |
| 1166 | − | ("upload_attachment", Scope::IssuesWrite), | |
| 1167 | − | ("edit_comment", Scope::IssuesWrite), | |
| 1168 | − | ("delete_comment", Scope::IssuesWrite), | |
| 1169 | − | ("react", Scope::IssuesWrite), | |
| 1170 | − | ("create_commit_comment", Scope::IssuesWrite), | |
| 1171 | − | ("update_commit_comment", Scope::IssuesWrite), | |
| 1172 | − | ("delete_commit_comment", Scope::IssuesWrite), | |
| 1177 | + | ("upload_attachment", Scope::WorkWrite), | |
| 1178 | + | ("create_commit_comment", Scope::WorkWrite), | |
| 1179 | + | ("update_commit_comment", Scope::WorkWrite), | |
| 1180 | + | ("delete_commit_comment", Scope::WorkWrite), | |
| 1173 | 1181 | // Looking up a ticket the work refers to, through the workspace's | |
| 1174 | − | // integrations, and bringing one in as an issue. | |
| 1175 | − | ("get_context", Scope::IssuesRead), | |
| 1176 | − | ("import_issue", Scope::IssuesWrite), | |
| 1177 | − | // Coordinating work on issues: claims, and the context to pick one up. | |
| 1178 | − | ("list_claims", Scope::IssuesRead), | |
| 1179 | − | ("get_work", Scope::IssuesRead), | |
| 1180 | − | ("claim_issue", Scope::IssuesWrite), | |
| 1181 | − | ("release_claim", Scope::IssuesWrite), | |
| 1182 | − | ("hand_off_issue", Scope::IssuesWrite), | |
| 1182 | + | // integrations, and bringing one in as a work item. | |
| 1183 | + | ("get_context", Scope::WorkRead), | |
| 1184 | + | ("import_work_items", Scope::WorkWrite), | |
| 1185 | + | // Coordinating work items: claims, and the context to pick one up. | |
| 1186 | + | ("list_claims", Scope::WorkRead), | |
| 1187 | + | ("get_work", Scope::WorkRead), | |
| 1188 | + | ("claim_work_item", Scope::WorkWrite), | |
| 1189 | + | ("release_claim", Scope::WorkWrite), | |
| 1190 | + | ("hand_off_work_item", Scope::WorkWrite), | |
| 1183 | 1191 | // Pull requests. | |
| 1184 | 1192 | ("list_pull_requests", Scope::PullRequestsRead), | |
| 1185 | 1193 | ("get_pull_request", Scope::PullRequestsRead), | |
| ⋯ | |||
| 1204 | 1212 | ("delete_pull_request_head_branch", Scope::PullRequestsWrite), | |
| 1205 | 1213 | ("restore_pull_request_head_branch", Scope::PullRequestsWrite), | |
| 1206 | 1214 | ("request_reviewers", Scope::PullRequestsWrite), | |
| 1215 | + | // Comments on pull requests, and reactions to them. | |
| 1216 | + | ("add_comment", Scope::PullRequestsWrite), | |
| 1217 | + | ("edit_comment", Scope::PullRequestsWrite), | |
| 1218 | + | ("delete_comment", Scope::PullRequestsWrite), | |
| 1219 | + | ("react", Scope::PullRequestsWrite), | |
| 1207 | 1220 | ("remove_requested_reviewers", Scope::PullRequestsWrite), | |
| 1208 | 1221 | // Harness sessions, which record how pull requests are made. | |
| 1209 | 1222 | ("get_session", Scope::PullRequestsRead), | |
| ⋯ | |||
| 1622 | 1635 | assert!(scope.as_str().starts_with(scope.resource().as_str())); | |
| 1623 | 1636 | assert!(scope.includes(scope)); | |
| 1624 | 1637 | } | |
| 1625 | − | assert_eq!(Scope::parse(" Issues:Write "), Some(Scope::IssuesWrite)); | |
| 1626 | − | assert_eq!(Scope::parse("issues"), None); | |
| 1638 | + | assert_eq!(Scope::parse(" Work:Write "), Some(Scope::WorkWrite)); | |
| 1639 | + | assert_eq!(Scope::parse("work"), None); | |
| 1627 | 1640 | } | |
| 1628 | 1641 | ||
| 1629 | 1642 | #[test] | |
| ⋯ | |||
| 1633 | 1646 | assert!(Scope::RepoWrite.includes(Scope::RepoCreate)); | |
| 1634 | 1647 | assert!(Scope::RepoCreate.includes(Scope::RepoRead)); | |
| 1635 | 1648 | assert!(!Scope::RepoCreate.includes(Scope::RepoWrite)); | |
| 1636 | − | assert!(Scope::IssuesWrite.includes(Scope::IssuesRead)); | |
| 1637 | − | assert!(!Scope::IssuesRead.includes(Scope::IssuesWrite)); | |
| 1649 | + | assert!(Scope::WorkWrite.includes(Scope::WorkRead)); | |
| 1650 | + | assert!(!Scope::WorkRead.includes(Scope::WorkWrite)); | |
| 1638 | 1651 | assert!(!Scope::RepoAdmin.includes(Scope::CodeWrite)); | |
| 1639 | − | assert!(!Scope::PullRequestsWrite.includes(Scope::IssuesWrite)); | |
| 1652 | + | assert!(!Scope::PullRequestsWrite.includes(Scope::WorkWrite)); | |
| 1640 | 1653 | } | |
| 1641 | 1654 | ||
| 1642 | 1655 | #[test] | |
| ⋯ | |||
| 1651 | 1664 | #[test] | |
| 1652 | 1665 | fn scopes_are_parsed_from_oauth_text_leaving_out_unknown_ones() { | |
| 1653 | 1666 | assert_eq!( | |
| 1654 | − | parse_scopes("issues:write repo:read,bogus:thing issues:write"), | |
| 1655 | − | vec![Scope::RepoRead, Scope::IssuesWrite] | |
| 1667 | + | parse_scopes("work:write repo:read,bogus:thing work:write"), | |
| 1668 | + | vec![Scope::RepoRead, Scope::WorkWrite] | |
| 1656 | 1669 | ); | |
| 1657 | − | assert_eq!(scopes_text(&[Scope::RepoRead, Scope::IssuesWrite]), "repo:read issues:write"); | |
| 1670 | + | assert_eq!(scopes_text(&[Scope::RepoRead, Scope::WorkWrite]), "repo:read work:write"); | |
| 1658 | 1671 | } | |
| 1659 | 1672 | ||
| 1660 | 1673 | #[test] | |
| 1661 | 1674 | fn the_oauth_default_is_the_agent_preset_and_never_admin() { | |
| 1662 | 1675 | let scopes = oauth_default(); | |
| 1663 | − | assert!(scopes.contains(&Scope::IssuesWrite)); | |
| 1676 | + | assert!(scopes.contains(&Scope::WorkWrite)); | |
| 1664 | 1677 | assert!(scopes.contains(&Scope::PullRequestsWrite)); | |
| 1665 | 1678 | assert!(!scopes.contains(&Scope::AgentsRun)); | |
| 1666 | 1679 | assert!(scopes.iter().all(|scope| !scope.dangerous()), "{scopes:?}"); | |
| ⋯ | |||
| 1682 | 1695 | assert_eq!(back, scope); | |
| 1683 | 1696 | } | |
| 1684 | 1697 | // But nothing hands them out, and asking for them gets the rest. | |
| 1685 | − | assert_eq!(parse_scopes("agents:run issues:read memory:write models:write"), vec![Scope::IssuesRead]); | |
| 1698 | + | assert_eq!(parse_scopes("agents:run work:read memory:write models:write"), vec![Scope::WorkRead]); | |
| 1686 | 1699 | assert!(!offered_scopes().iter().any(|scope| scope.resource().retired())); | |
| 1687 | 1700 | assert!(!everything().iter().any(|scope| scope.resource().retired())); | |
| 1688 | 1701 | for preset in Preset::ALL { | |
| ⋯ | |||
| 1829 | 1842 | let job = TokenAccess { | |
| 1830 | 1843 | repo: Some("acme/web".into()), | |
| 1831 | 1844 | job: Some(JobToken { run_id: "run_1".into(), job_id: "job_1".into(), pull_requests: false }), | |
| 1832 | − | ..token(&[Scope::RepoRead, Scope::IssuesWrite, Scope::IssuesRead, Scope::PullRequestsWrite]) | |
| 1845 | + | ..token(&[Scope::RepoRead, Scope::WorkWrite, Scope::WorkRead, Scope::PullRequestsWrite]) | |
| 1833 | 1846 | }; | |
| 1834 | − | assert!(decide(&job, "create_issue", &json!({ "repo": "acme/web" })).allowed); | |
| 1835 | − | assert!(decide(&job, "create_issue", &json!({ "repo": "Acme/Web" })).allowed, "names compare without case"); | |
| 1836 | − | let elsewhere = decide(&job, "create_issue", &json!({ "repo": "acme/api" })); | |
| 1847 | + | assert!(decide(&job, "open_work_item", &json!({ "repo": "acme/web" })).allowed); | |
| 1848 | + | assert!(decide(&job, "open_work_item", &json!({ "repo": "Acme/Web" })).allowed, "names compare without case"); | |
| 1849 | + | let elsewhere = decide(&job, "open_work_item", &json!({ "repo": "acme/api" })); | |
| 1837 | 1850 | assert!(!elsewhere.allowed); | |
| 1838 | 1851 | assert_eq!(elsewhere.rule, "token:repository"); | |
| 1839 | 1852 | // Nothing beyond the one repository, a workspace's listing included. | |
| ⋯ | |||
| 1882 | 1895 | #[test] | |
| 1883 | 1896 | fn a_narrowed_token_only_reads_outside_its_workspace() { | |
| 1884 | 1897 | let reach = TokenReach { workspace: Some("acme".into()), repositories: RepositorySelection::All, repo_ids: Vec::new() }; | |
| 1885 | − | let fine = TokenAccess { reach: Some(reach), ..token(&[Scope::RepoRead, Scope::IssuesRead, Scope::IssuesWrite]) }; | |
| 1886 | − | assert!(decide(&fine, "create_issue", &json!({ "repo": "acme/web" })).allowed); | |
| 1887 | − | assert!(decide(&fine, "create_issue", &json!({ "repo": "Acme/web" })).allowed); | |
| 1888 | − | let elsewhere = decide(&fine, "create_issue", &json!({ "repo": "globex/site" })); | |
| 1898 | + | let fine = TokenAccess { reach: Some(reach), ..token(&[Scope::RepoRead, Scope::WorkRead, Scope::WorkWrite]) }; | |
| 1899 | + | assert!(decide(&fine, "open_work_item", &json!({ "repo": "acme/web" })).allowed); | |
| 1900 | + | assert!(decide(&fine, "open_work_item", &json!({ "repo": "Acme/web" })).allowed); | |
| 1901 | + | let elsewhere = decide(&fine, "open_work_item", &json!({ "repo": "globex/site" })); | |
| 1889 | 1902 | assert_eq!(elsewhere.rule, "token:resource-owner"); | |
| 1890 | 1903 | assert!(elsewhere.reason.unwrap().contains("acme")); | |
| 1891 | − | assert!(decide(&fine, "get_issue", &json!({ "repo": "globex/site" })).allowed, "public repositories elsewhere read"); | |
| 1904 | + | assert!(decide(&fine, "get_work_item", &json!({ "repo": "globex/site" })).allowed, "public repositories elsewhere read"); | |
| 1892 | 1905 | assert!(!decide(&fine, "create_pull_request", &json!({ "repo": "acme/web" })).allowed, "its scopes still hold"); | |
| 1893 | − | let mine = TokenAccess { reach: Some(TokenReach::default()), ..token(&[Scope::IssuesWrite]) }; | |
| 1894 | − | assert!(decide(&mine, "create_issue", &json!({ "repo": "acme/web" })).reason.unwrap().contains("your account")); | |
| 1906 | + | let mine = TokenAccess { reach: Some(TokenReach::default()), ..token(&[Scope::WorkWrite]) }; | |
| 1907 | + | assert!(decide(&mine, "open_work_item", &json!({ "repo": "acme/web" })).reason.unwrap().contains("your account")); | |
| 1895 | 1908 | assert!(fine.covers_repo("rep_1", "acme") && !fine.covers_repo("rep_1", "globex")); | |
| 1896 | 1909 | let selected = TokenReach { workspace: Some("acme".into()), repositories: RepositorySelection::Selected, repo_ids: vec!["rep_1".into()] }; | |
| 1897 | 1910 | assert!(selected.covers("rep_1", "ACME") && !selected.covers("rep_2", "acme")); | |
| ⋯ | |||
| 1928 | 1941 | #[test] | |
| 1929 | 1942 | fn permissions_are_scopes_read_per_resource() { | |
| 1930 | 1943 | let asked: std::collections::BTreeMap<String, String> = | |
| 1931 | − | [("issues", "write"), ("repo", "read"), ("code", "none"), ("packages", "delete")].iter().map(|(a, b)| ((*a).to_owned(), (*b).to_owned())).collect(); | |
| 1944 | + | [("work", "write"), ("repo", "read"), ("code", "none"), ("packages", "delete")].iter().map(|(a, b)| ((*a).to_owned(), (*b).to_owned())).collect(); | |
| 1932 | 1945 | let scopes = resolve_permissions(&asked, true).unwrap(); | |
| 1933 | − | assert_eq!(scopes, vec![Scope::RepoRead, Scope::PackagesDelete, Scope::IssuesWrite]); | |
| 1946 | + | assert_eq!(scopes, vec![Scope::RepoRead, Scope::PackagesDelete, Scope::WorkWrite]); | |
| 1934 | 1947 | let back = permissions_of(&scopes); | |
| 1935 | − | assert_eq!(back.get("issues").map(String::as_str), Some("write")); | |
| 1948 | + | assert_eq!(back.get("work").map(String::as_str), Some("write")); | |
| 1936 | 1949 | assert_eq!(back.get("packages").map(String::as_str), Some("delete")); | |
| 1937 | 1950 | assert!(!back.contains_key("code")); | |
| 1938 | 1951 | // Lower levels held beside a higher one say nothing more. | |
| ⋯ | |||
| 1949 | 1962 | fn permissions_are_checked_by_name_level_and_owner() { | |
| 1950 | 1963 | let one = |name: &str, level: &str| -> std::collections::BTreeMap<String, String> { [(name.to_owned(), level.to_owned())].into() }; | |
| 1951 | 1964 | assert!(resolve_permissions(&one("wiki", "read"), true).unwrap_err().contains("wiki")); | |
| 1952 | − | assert!(resolve_permissions(&one("issues", "admin"), true).unwrap_err().contains("read, write")); | |
| 1965 | + | assert!(resolve_permissions(&one("work", "admin"), true).unwrap_err().contains("read, write")); | |
| 1953 | 1966 | assert!(resolve_permissions(&one("workflow_files", "read"), true).is_err(), "workflow files are written only"); | |
| 1954 | 1967 | assert!(resolve_permissions(&one("notifications", "read"), false).unwrap_err().contains("account")); | |
| 1955 | 1968 | assert_eq!(resolve_permissions(&one("notifications", "read"), true).unwrap(), vec![Scope::NotificationsRead]); | |
| ⋯ | |||
| 1972 | 1985 | ||
| 1973 | 1986 | #[test] | |
| 1974 | 1987 | fn a_missing_scope_is_named() { | |
| 1975 | − | let read = token(&[Scope::IssuesRead]); | |
| 1976 | − | assert!(decide(&read, "get_issue", &json!({})).allowed); | |
| 1988 | + | let read = token(&[Scope::WorkRead]); | |
| 1989 | + | assert!(decide(&read, "get_work_item", &json!({})).allowed); | |
| 1977 | 1990 | assert!(decide(&read, "whoami", &json!({})).allowed); | |
| 1978 | − | let refused = decide(&read, "create_issue", &json!({})); | |
| 1991 | + | let refused = decide(&read, "open_work_item", &json!({})); | |
| 1979 | 1992 | assert!(!refused.allowed); | |
| 1980 | − | assert_eq!(refused.reason.as_deref(), Some("This access token needs the issues:write scope to use create_issue.")); | |
| 1993 | + | assert_eq!(refused.reason.as_deref(), Some("This access token needs the work:write scope to use open_work_item.")); | |
| 1981 | 1994 | // An operation the table does not know needs full access. | |
| 1982 | 1995 | assert!(!decide(&read, "something_new", &json!({})).allowed); | |
| 1983 | 1996 | } | |
| ⋯ | |||
| 2007 | 2020 | let refused = decide_git(&reader, true, false); | |
| 2008 | 2021 | assert!(!refused.allowed); | |
| 2009 | 2022 | assert!(refused.reason.unwrap().contains("code:write")); | |
| 2010 | − | let issues = token(&[Scope::IssuesWrite]); | |
| 2011 | − | assert!(!decide_git(&issues, false, false).allowed); | |
| 2012 | − | assert!(decide_git(&issues, false, true).allowed, "public code needs no scope"); | |
| 2013 | − | assert!(!decide_git(&issues, true, true).allowed, "pushing to public code still needs code:write"); | |
| 2023 | + | let work = token(&[Scope::WorkWrite]); | |
| 2024 | + | assert!(!decide_git(&work, false, false).allowed); | |
| 2025 | + | assert!(decide_git(&work, false, true).allowed, "public code needs no scope"); | |
| 2026 | + | assert!(!decide_git(&work, true, true).allowed, "pushing to public code still needs code:write"); | |
| 2014 | 2027 | let writer = token(&[Scope::CodeWrite]); | |
| 2015 | 2028 | assert!(decide_git(&writer, true, false).allowed); | |
| 2016 | 2029 | assert!(decide_git(&writer, false, false).allowed, "code:write includes code:read"); | |
| ⋯ | |||
| 2040 | 2053 | ||
| 2041 | 2054 | #[test] | |
| 2042 | 2055 | fn token_access_travels_as_json() { | |
| 2043 | − | let access = token(&[Scope::IssuesRead]); | |
| 2056 | + | let access = token(&[Scope::WorkRead]); | |
| 2044 | 2057 | let wire = serde_json::to_value(&access).unwrap(); | |
| 2045 | − | assert_eq!(wire["scopes"], json!(["issues:read"])); | |
| 2058 | + | assert_eq!(wire["scopes"], json!(["work:read"])); | |
| 2046 | 2059 | assert!(wire.get("resources").is_none()); | |
| 2047 | 2060 | let back: TokenAccess = serde_json::from_value(wire).unwrap(); | |
| 2048 | 2061 | assert_eq!(back, access); | |
| ⋯ | |||
| 2052 | 2065 | // whatever its owner can. | |
| 2053 | 2066 | let older: TokenAccess = serde_json::from_value(json!({ | |
| 2054 | 2067 | "token_id": "tok_1", | |
| 2055 | − | "scopes": ["issues:read"], | |
| 2068 | + | "scopes": ["work:read"], | |
| 2056 | 2069 | "resources": { "kind": "repositories", "repositories": ["acme/rocket"] }, | |
| 2057 | 2070 | })) | |
| 2058 | 2071 | .unwrap(); | |
| 4 | 4 | * test keeps the tables here the same. | |
| 5 | 5 | * | |
| 6 | 6 | * A token's permissions are its scopes read per resource: each resource | |
| 7 | − | * at none or one level, such as issues: write. What a request may do is | |
| 7 | + | * at none or one level, such as work: write. What a request may do is | |
| 8 | 8 | * the intersection of the owner's role, the token's reach (the workspace | |
| 9 | 9 | * it is made for, and its repositories) and its permissions. | |
| 10 | 10 | */ | |
| ⋯ | |||
| 14 | 14 | | "code" | |
| 15 | 15 | | "security" | |
| 16 | 16 | | "packages" | |
| 17 | − | | "issues" | |
| 17 | + | | "work" | |
| 18 | 18 | | "pull_requests" | |
| 19 | 19 | | "agents" | |
| 20 | 20 | | "workflows" | |
| ⋯ | |||
| 39 | 39 | ||
| 40 | 40 | /** Every scope, grouped by resource, least first. */ | |
| 41 | 41 | export const SCOPES = [ | |
| 42 | − | { scope: "repo:read", description: "See repositories, their settings, labels, timelines, releases, languages, contributors and security alerts, and search" }, | |
| 42 | + | { scope: "repo:read", description: "See repositories, their settings, timelines, releases, languages, contributors and security alerts, and search" }, | |
| 43 | 43 | { scope: "repo:create", description: "Create repositories, and nothing that changes or deletes one" }, | |
| 44 | 44 | { scope: "repo:write", description: "Create repositories, create, rename and delete branches, change how pull requests merge and publish releases" }, | |
| 45 | 45 | { scope: "repo:admin", description: "Rename, archive, transfer, delete or change who can see a repository, change its rulesets, and dismiss security alerts" }, | |
| ⋯ | |||
| 50 | 50 | { scope: "packages:read", description: "Pull container images and install private packages" }, | |
| 51 | 51 | { scope: "packages:write", description: "Push container images and publish packages" }, | |
| 52 | 52 | { scope: "packages:delete", description: "Delete and restore packages and their versions" }, | |
| 53 | − | { scope: "issues:read", description: "Read issues and comments" }, | |
| 54 | − | { scope: "issues:write", description: "Open, edit, close and comment on issues" }, | |
| 53 | + | { scope: "work:read", description: "Read work items, their comments, labels, milestones and claims" }, | |
| 54 | + | { scope: "work:write", description: "Open, edit, link, close and comment on work items, claim them, and manage labels and milestones" }, | |
| 55 | 55 | { scope: "pull_requests:read", description: "Read pull requests, their changes, sessions and merge queues" }, | |
| 56 | − | { scope: "pull_requests:write", description: "Open, review, close and merge pull requests" }, | |
| 56 | + | { scope: "pull_requests:write", description: "Open, review, comment on, close and merge pull requests" }, | |
| 57 | 57 | { scope: "workflows:read", description: "Read workflows, runs and logs" }, | |
| 58 | 58 | { scope: "workflows:write", description: "Run, cancel, rerun and turn workflows on or off" }, | |
| 59 | 59 | { scope: "workflow_files:write", description: "Add, change and delete workflow files under .g1t/workflows and .github/workflows, with git or the API" }, | |
| ⋯ | |||
| 123 | 123 | { resource: "code", label: "Code", group: "repository" }, | |
| 124 | 124 | { resource: "security", label: "Security", group: "repository" }, | |
| 125 | 125 | { resource: "packages", label: "Packages", group: "repository" }, | |
| 126 | − | { resource: "issues", label: "Issues", group: "repository" }, | |
| 126 | + | { resource: "work", label: "Work", group: "repository" }, | |
| 127 | 127 | { resource: "pull_requests", label: "Pull requests", group: "repository" }, | |
| 128 | 128 | { resource: "workflows", label: "Workflows", group: "repository" }, | |
| 129 | 129 | { resource: "workflow_files", label: "Workflow files", group: "repository" }, | |
| ⋯ | |||
| 252 | 252 | /** Starting points for choosing scopes. `*` is full access. No preset gives chat: an application gets it only by asking for it. */ | |
| 253 | 253 | export const PRESET_SCOPES = { | |
| 254 | 254 | read_only: [ | |
| 255 | − | "repo:read", "code:read", "security:read", "packages:read", "issues:read", "pull_requests:read", "workflows:read", "checks:read", "deployments:read", "observability:read", "account:read", "notifications:read", "workspace:read", "billing:read", "access:read", "webhooks:read", "secrets:read", "runners:read", "artifacts:read", | |
| 255 | + | "repo:read", "code:read", "security:read", "packages:read", "work:read", "pull_requests:read", "workflows:read", "checks:read", "deployments:read", "observability:read", "account:read", "notifications:read", "workspace:read", "billing:read", "access:read", "webhooks:read", "secrets:read", "runners:read", "artifacts:read", | |
| 256 | 256 | ] as const, | |
| 257 | 257 | agent: [ | |
| 258 | − | "repo:read", "repo:create", "code:read", "code:write", "security:read", "packages:read", "issues:read", "issues:write", "pull_requests:read", "pull_requests:write", "workflows:read", "checks:read", "deployments:read", "observability:read", "account:read", "notifications:read", "notifications:write", "workspace:read", "billing:read", "access:read", "webhooks:read", "secrets:read", "artifacts:read", | |
| 258 | + | "repo:read", "repo:create", "code:read", "code:write", "security:read", "packages:read", "work:read", "work:write", "pull_requests:read", "pull_requests:write", "workflows:read", "checks:read", "deployments:read", "observability:read", "account:read", "notifications:read", "notifications:write", "workspace:read", "billing:read", "access:read", "webhooks:read", "secrets:read", "artifacts:read", | |
| 259 | 259 | ] as const, | |
| 260 | 260 | ci: [ | |
| 261 | 261 | "repo:read", "code:read", "code:write", "packages:read", "packages:write", "workflows:read", "workflows:write", "checks:read", "checks:write", "deployments:read", "deployments:write", | |
| ⋯ | |||
| 267 | 267 | ||
| 268 | 268 | export const PRESETS: { id: PresetId; label: string; description: string }[] = [ | |
| 269 | 269 | { id: "read_only", label: "Read only", description: "Read everything you can read; change nothing." }, | |
| 270 | − | { id: "agent", label: "Agent", description: "Read everything, work on issues and pull requests, push code, and create repositories." }, | |
| 270 | + | { id: "agent", label: "Agent", description: "Read everything, work on work items and pull requests, push code, and create repositories." }, | |
| 271 | 271 | { id: "ci", label: "CI", description: "Clone and push code, push and pull packages, run workflows, and report checks and deployments." }, | |
| 272 | 272 | { id: "full", label: "Full access", description: "Everything you can do, including deleting repositories and changing who has access." }, | |
| 273 | 273 | ]; | |
| ⋯ | |||
| 402 | 402 | ["create_release", "repo:write"], | |
| 403 | 403 | ["update_release", "repo:write"], | |
| 404 | 404 | ["delete_release", "repo:write"], | |
| 405 | − | ["list_labels", "repo:read"], | |
| 406 | − | ["list_milestones", "repo:read"], | |
| 407 | − | ["get_milestone", "repo:read"], | |
| 408 | − | ["create_label", "issues:write"], | |
| 409 | − | ["update_label", "issues:write"], | |
| 410 | − | ["delete_label", "issues:write"], | |
| 411 | − | ["add_default_labels", "issues:write"], | |
| 412 | − | ["create_milestone", "issues:write"], | |
| 413 | − | ["update_milestone", "issues:write"], | |
| 414 | − | ["delete_milestone", "issues:write"], | |
| 405 | + | ["list_labels", "work:read"], | |
| 406 | + | ["list_milestones", "work:read"], | |
| 407 | + | ["get_milestone", "work:read"], | |
| 408 | + | ["create_label", "work:write"], | |
| 409 | + | ["update_label", "work:write"], | |
| 410 | + | ["delete_label", "work:write"], | |
| 411 | + | ["add_default_labels", "work:write"], | |
| 412 | + | ["create_milestone", "work:write"], | |
| 413 | + | ["update_milestone", "work:write"], | |
| 414 | + | ["delete_milestone", "work:write"], | |
| 415 | 415 | ["get_repo_settings", "repo:read"], | |
| 416 | 416 | ["list_check_names", "repo:read"], | |
| 417 | 417 | ["list_deleted_repos", "repo:read"], | |
| ⋯ | |||
| 484 | 484 | ["set_security_advisory_state", "security:write"], | |
| 485 | 485 | ["report_vulnerability", "security:write"], | |
| 486 | 486 | ["review_vulnerability_report", "security:write"], | |
| 487 | − | ["list_issues", "issues:read"], | |
| 488 | − | ["get_issue", "issues:read"], | |
| 489 | − | ["create_issue", "issues:write"], | |
| 490 | − | ["update_issue", "issues:write"], | |
| 491 | − | ["list_issue_labels", "issues:read"], | |
| 492 | − | ["add_issue_labels", "issues:write"], | |
| 493 | − | ["set_issue_labels", "issues:write"], | |
| 494 | − | ["remove_issue_labels", "issues:write"], | |
| 495 | − | ["close_issue", "issues:write"], | |
| 496 | − | ["reopen_issue", "issues:write"], | |
| 497 | − | ["add_comment", "issues:write"], | |
| 498 | − | ["upload_attachment", "issues:write"], | |
| 499 | − | ["edit_comment", "issues:write"], | |
| 500 | − | ["delete_comment", "issues:write"], | |
| 501 | − | ["react", "issues:write"], | |
| 502 | − | ["create_commit_comment", "issues:write"], | |
| 503 | − | ["update_commit_comment", "issues:write"], | |
| 504 | − | ["delete_commit_comment", "issues:write"], | |
| 505 | − | ["get_context", "issues:read"], | |
| 506 | − | ["import_issue", "issues:write"], | |
| 507 | − | ["list_claims", "issues:read"], | |
| 508 | − | ["get_work", "issues:read"], | |
| 509 | − | ["claim_issue", "issues:write"], | |
| 510 | − | ["release_claim", "issues:write"], | |
| 511 | − | ["hand_off_issue", "issues:write"], | |
| 487 | + | ["list_work_items", "work:read"], | |
| 488 | + | ["list_repo_work_items", "work:read"], | |
| 489 | + | ["get_work_item", "work:read"], | |
| 490 | + | ["list_work_item_links", "work:read"], | |
| 491 | + | ["resolve_ref", "work:read"], | |
| 492 | + | ["open_work_item", "work:write"], | |
| 493 | + | ["update_work_item", "work:write"], | |
| 494 | + | ["close_work_item", "work:write"], | |
| 495 | + | ["reopen_work_item", "work:write"], | |
| 496 | + | ["link_work_item", "work:write"], | |
| 497 | + | ["unlink_work_item", "work:write"], | |
| 498 | + | ["list_work_item_labels", "work:read"], | |
| 499 | + | ["add_work_item_labels", "work:write"], | |
| 500 | + | ["set_work_item_labels", "work:write"], | |
| 501 | + | ["remove_work_item_labels", "work:write"], | |
| 502 | + | ["list_work_item_decisions", "work:read"], | |
| 503 | + | ["add_work_item_comment", "work:write"], | |
| 504 | + | ["edit_work_item_comment", "work:write"], | |
| 505 | + | ["delete_work_item_comment", "work:write"], | |
| 506 | + | ["react_to_work_item", "work:write"], | |
| 507 | + | ["upload_attachment", "work:write"], | |
| 508 | + | ["create_commit_comment", "work:write"], | |
| 509 | + | ["update_commit_comment", "work:write"], | |
| 510 | + | ["delete_commit_comment", "work:write"], | |
| 511 | + | ["get_context", "work:read"], | |
| 512 | + | ["import_work_items", "work:write"], | |
| 513 | + | ["list_claims", "work:read"], | |
| 514 | + | ["get_work", "work:read"], | |
| 515 | + | ["claim_work_item", "work:write"], | |
| 516 | + | ["release_claim", "work:write"], | |
| 517 | + | ["hand_off_work_item", "work:write"], | |
| 512 | 518 | ["list_pull_requests", "pull_requests:read"], | |
| 513 | 519 | ["get_pull_request", "pull_requests:read"], | |
| 514 | 520 | ["get_pull_request_changes", "pull_requests:read"], | |
| ⋯ | |||
| 532 | 538 | ["delete_pull_request_head_branch", "pull_requests:write"], | |
| 533 | 539 | ["restore_pull_request_head_branch", "pull_requests:write"], | |
| 534 | 540 | ["request_reviewers", "pull_requests:write"], | |
| 541 | + | ["add_comment", "pull_requests:write"], | |
| 542 | + | ["edit_comment", "pull_requests:write"], | |
| 543 | + | ["delete_comment", "pull_requests:write"], | |
| 544 | + | ["react", "pull_requests:write"], | |
| 535 | 545 | ["remove_requested_reviewers", "pull_requests:write"], | |
| 536 | 546 | ["get_session", "pull_requests:read"], | |
| 537 | 547 | ["list_sessions", "pull_requests:read"], | |
| ⋯ | |||
| 748 | 758 | { id: "code", label: "Repositories & code", scopes: ["repo:read", "repo:create", "repo:write", "code:read", "code:write"] }, | |
| 749 | 759 | { id: "security", label: "Security", scopes: ["security:read", "security:write"] }, | |
| 750 | 760 | { id: "packages", label: "Packages", scopes: ["packages:read", "packages:write"] }, | |
| 751 | − | { id: "work", label: "Issues & pull requests", scopes: ["issues:read", "issues:write", "pull_requests:read", "pull_requests:write"] }, | |
| 761 | + | { id: "work", label: "Work & pull requests", scopes: ["work:read", "work:write", "pull_requests:read", "pull_requests:write"] }, | |
| 752 | 762 | { id: "workflows", label: "Workflows", scopes: ["workflows:read", "workflows:write", "workflow_files:write"] }, | |
| 753 | 763 | { id: "checks", label: "Checks", scopes: ["checks:read", "checks:write"] }, | |
| 754 | 764 | { id: "deployments", label: "Deployments", scopes: ["deployments:read", "deployments:write"] }, | |
| 1414 | 1414 | assert!(restricted.allows(Scope::CodeRead)); | |
| 1415 | 1415 | assert!(!restricted.allows(Scope::CodeWrite)); | |
| 1416 | 1416 | assert!(!restricted.allows(Scope::PullRequestsWrite)); | |
| 1417 | + | assert!(!restricted.allows(Scope::WorkWrite)); | |
| 1417 | 1418 | // A pull request from outside reads, whatever it asks for. | |
| 1418 | 1419 | let outside = token(&Permissions::all(Access::Write).read_only()); | |
| 1419 | 1420 | assert!(Scope::ALL.iter().filter(|scope| outside.allows(**scope)).all(|scope| scope.level() == Level::Read)); |
| 1 | + | -- Issues became work items (g1t #57): the `issues` resource is now `work`, | |
| 2 | + | -- so `issues:read` and `issues:write` are `work:read` and `work:write`. | |
| 3 | + | -- Every stored scope list is rewritten, so tokens, OAuth grants and codes | |
| 4 | + | -- not yet exchanged keep exactly the access they had. A fine-grained | |
| 5 | + | -- token's old named permissions (retired in 0041, read by nothing) follow | |
| 6 | + | -- for tidiness. | |
| 7 | + | UPDATE access_tokens SET scopes = REPLACE(scopes, 'issues:', 'work:') WHERE scopes LIKE '%issues:%'; | |
| 8 | + | UPDATE oauth_grants SET scopes = REPLACE(scopes, 'issues:', 'work:') WHERE scopes LIKE '%issues:%'; | |
| 9 | + | UPDATE oauth_codes SET scopes = REPLACE(scopes, 'issues:', 'work:') WHERE scopes LIKE '%issues:%'; | |
| 10 | + | UPDATE access_tokens SET permissions = REPLACE(permissions, '"issues":', '"work":') WHERE permissions LIKE '%"issues":%'; | |
| 11 | + | -- A run's token lists the operations it may use by name: the work item | |
| 12 | + | -- operations took the issue ones' places, and commenting on a work item | |
| 13 | + | -- is its own operation beside commenting on a pull request. | |
| 14 | + | UPDATE access_tokens SET agent_scope = | |
| 15 | + | REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(agent_scope, | |
| 16 | + | '"get_issue"', '"get_work_item"'), | |
| 17 | + | '"list_issues"', '"list_work_items"'), | |
| 18 | + | '"create_issue"', '"open_work_item"'), | |
| 19 | + | '"update_issue"', '"update_work_item"'), | |
| 20 | + | '"close_issue"', '"close_work_item"'), | |
| 21 | + | '"reopen_issue"', '"reopen_work_item"'), | |
| 22 | + | '"claim_issue"', '"claim_work_item"'), | |
| 23 | + | '"hand_off_issue"', '"hand_off_work_item"'), | |
| 24 | + | '"import_issue"', '"import_work_items"'), | |
| 25 | + | '"list_issue_labels"', '"list_work_item_labels"'), | |
| 26 | + | '"add_issue_labels"', '"add_work_item_labels"'), | |
| 27 | + | '"set_issue_labels"', '"set_work_item_labels"'), | |
| 28 | + | '"remove_issue_labels"', '"remove_work_item_labels"'), | |
| 29 | + | '"add_comment"', '"add_comment","add_work_item_comment"') | |
| 30 | + | WHERE agent_scope IS NOT NULL; |
| 89 | 89 | assert!(access.allows(Scope::PullRequestsRead)); | |
| 90 | 90 | assert!(access.allows(Scope::RepoRead)); | |
| 91 | 91 | assert!(!access.allows(Scope::PullRequestsWrite)); | |
| 92 | − | assert!(!access.allows(Scope::IssuesWrite)); | |
| 92 | + | assert!(!access.allows(Scope::WorkWrite)); | |
| 93 | 93 | assert!(!access.allows(Scope::CodeWrite)); | |
| 94 | 94 | // A person's conversations are never in it. | |
| 95 | 95 | assert!(!access.allows(Scope::ChatRead)); |
| 1275 | 1275 | ||
| 1276 | 1276 | fn workspace_token(selection: RepositorySelection) -> g1t_contracts::scopes::TokenAccess { | |
| 1277 | 1277 | g1t_contracts::scopes::TokenAccess { | |
| 1278 | − | scopes: Some(vec!["repo:read".into(), "code:write".into(), "issues:write".into()]), | |
| 1278 | + | scopes: Some(vec!["repo:read".into(), "code:write".into(), "work:write".into()]), | |
| 1279 | 1279 | reach: Some(workspace_token_reach("acme".into(), selection, vec!["rep_1".into()])), | |
| 1280 | 1280 | ..Default::default() | |
| 1281 | 1281 | } | |
| ⋯ | |||
| 1286 | 1286 | use g1t_contracts::scopes::decide; | |
| 1287 | 1287 | for selection in [RepositorySelection::All, RepositorySelection::Selected] { | |
| 1288 | 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")); | |
| 1289 | + | let item = |repo: &str| serde_json::json!({ "repo": repo, "title": "spam" }); | |
| 1290 | + | let elsewhere = decide(&token, "open_work_item", &item("other/public")); | |
| 1291 | 1291 | assert!(!elsewhere.allowed, "{selection:?}"); | |
| 1292 | 1292 | assert_eq!(elsewhere.rule, "token:resource-owner"); | |
| 1293 | − | assert!(decide(&token, "create_issue", &issue("acme/rocket")).allowed, "{selection:?}"); | |
| 1293 | + | assert!(decide(&token, "open_work_item", &item("acme/rocket")).allowed, "{selection:?}"); | |
| 1294 | 1294 | // Reading a public repository elsewhere is as anyone may. | |
| 1295 | 1295 | assert!(decide(&token, "get_repo", &serde_json::json!({ "repo": "other/public" })).allowed, "{selection:?}"); | |
| 1296 | 1296 | } | |
| ⋯ | |||
| 1406 | 1406 | #[test] | |
| 1407 | 1407 | fn the_migration_sets_full_access_out_as_every_permission() { | |
| 1408 | 1408 | use g1t_contracts::scopes::{Resource, ResourceGroup, top_scopes}; | |
| 1409 | − | let sql = include_str!("../migrations/0041_one_kind_of_token.sql"); | |
| 1409 | + | // As 0054 rewrote them, when `issues` became `work`. | |
| 1410 | + | let sql = include_str!("../migrations/0041_one_kind_of_token.sql").replace("issues:", "work:"); | |
| 1410 | 1411 | let then: Vec<Scope> = top_scopes(&Scope::ALL) | |
| 1411 | 1412 | .into_iter() | |
| 1412 | 1413 | .filter(|scope| !matches!(scope.resource(), Resource::Artifacts | Resource::Chat | Resource::Observability)) | |
| 516 | 516 | assert_eq!(stored_scopes(None), (None, true)); | |
| 517 | 517 | assert_eq!(stored_scopes(Some("*")), (None, false)); | |
| 518 | 518 | assert_eq!( | |
| 519 | − | stored_scopes(Some("issues:write repo:read nonsense")), | |
| 520 | − | (Some(vec!["repo:read".to_owned(), "issues:write".to_owned()]), false) | |
| 519 | + | stored_scopes(Some("work:write repo:read nonsense")), | |
| 520 | + | (Some(vec!["repo:read".to_owned(), "work:write".to_owned()]), false) | |
| 521 | 521 | ); | |
| 522 | 522 | } | |
| 523 | 523 | ||
| 524 | 524 | #[test] | |
| 525 | 525 | fn scopes_are_stored_as_text() { | |
| 526 | − | let grant = Grant::asked(&Some(vec!["issues:read".to_owned(), "bogus".to_owned()])); | |
| 527 | − | assert_eq!(grant.scopes_column(), "issues:read"); | |
| 526 | + | let grant = Grant::asked(&Some(vec!["work:read".to_owned(), "bogus".to_owned()])); | |
| 527 | + | assert_eq!(grant.scopes_column(), "work:read"); | |
| 528 | 528 | assert_eq!(Grant::asked(&None).scopes_column(), "*"); | |
| 529 | 529 | assert_eq!(Grant::asked(&Some(vec![])).scopes_column(), ""); | |
| 530 | 530 | } |
Comments
Sign in to comment.