| 34 | 34 | | PullRequests, |
| 35 | 35 | | Agents, |
| 36 | 36 | | Workflows, |
| 37 | + | WorkflowFiles, |
| 37 | 38 | | Checks, |
| 38 | 39 | | Deployments, |
| 39 | 40 | | Memory, |
| ⋯ |
| 45 | 46 | | } |
| 46 | 47 | | |
| 47 | 48 | | impl Resource { |
| 48 | | − | pub const ALL: [Resource; 20] = [ |
| 49 | + | pub const ALL: [Resource; 21] = [ |
| 49 | 50 | | Resource::Repo, |
| 50 | 51 | | Resource::Code, |
| 51 | 52 | | Resource::Security, |
| ⋯ |
| 54 | 55 | | Resource::PullRequests, |
| 55 | 56 | | Resource::Agents, |
| 56 | 57 | | Resource::Workflows, |
| 58 | + | Resource::WorkflowFiles, |
| 57 | 59 | | Resource::Checks, |
| 58 | 60 | | Resource::Deployments, |
| 59 | 61 | | Resource::Memory, |
| ⋯ |
| 82 | 84 | | Resource::PullRequests => "pull_requests", |
| 83 | 85 | | Resource::Agents => "agents", |
| 84 | 86 | | Resource::Workflows => "workflows", |
| 87 | + | Resource::WorkflowFiles => "workflow_files", |
| 85 | 88 | | Resource::Checks => "checks", |
| 86 | 89 | | Resource::Deployments => "deployments", |
| 87 | 90 | | Resource::Memory => "memory", |
| ⋯ |
| 108 | 111 | | Resource::PullRequests => "Pull requests", |
| 109 | 112 | | Resource::Agents => "g1t agents", |
| 110 | 113 | | Resource::Workflows => "Workflows", |
| 114 | + | Resource::WorkflowFiles => "Workflow files", |
| 111 | 115 | | Resource::Checks => "Checks and statuses", |
| 112 | 116 | | Resource::Deployments => "Deployments", |
| 113 | 117 | | Resource::Memory => "Memory and context", |
| ⋯ |
| 165 | 169 | | AgentsRun, |
| 166 | 170 | | WorkflowsRead, |
| 167 | 171 | | WorkflowsWrite, |
| 172 | + | WorkflowFilesWrite, |
| 168 | 173 | | ChecksRead, |
| 169 | 174 | | ChecksWrite, |
| 170 | 175 | | DeploymentsRead, |
| ⋯ |
| 193 | 198 | | |
| 194 | 199 | | impl Scope { |
| 195 | 200 | | /// Every scope, grouped by resource, least first. |
| 196 | | − | pub const ALL: [Scope; 41] = [ |
| 201 | + | pub const ALL: [Scope; 42] = [ |
| 197 | 202 | | Scope::RepoRead, |
| 198 | 203 | | Scope::RepoWrite, |
| 199 | 204 | | Scope::RepoAdmin, |
| ⋯ |
| 211 | 216 | | Scope::AgentsRun, |
| 212 | 217 | | Scope::WorkflowsRead, |
| 213 | 218 | | Scope::WorkflowsWrite, |
| 219 | + | Scope::WorkflowFilesWrite, |
| 214 | 220 | | Scope::ChecksRead, |
| 215 | 221 | | Scope::ChecksWrite, |
| 216 | 222 | | Scope::DeploymentsRead, |
| ⋯ |
| 256 | 262 | | Scope::AgentsRun => "agents:run", |
| 257 | 263 | | Scope::WorkflowsRead => "workflows:read", |
| 258 | 264 | | Scope::WorkflowsWrite => "workflows:write", |
| 265 | + | Scope::WorkflowFilesWrite => "workflow_files:write", |
| 259 | 266 | | Scope::ChecksRead => "checks:read", |
| 260 | 267 | | Scope::ChecksWrite => "checks:write", |
| 261 | 268 | | Scope::DeploymentsRead => "deployments:read", |
| ⋯ |
| 338 | 345 | | Scope::AgentsRun => "Put g1t agents to work and message them, which uses the workspace's money", |
| 339 | 346 | | Scope::WorkflowsRead => "Read workflows, runs and logs", |
| 340 | 347 | | Scope::WorkflowsWrite => "Run, cancel, rerun and turn workflows on or off", |
| 348 | + | Scope::WorkflowFilesWrite => "Add, change and delete workflow files under .g1t/workflows and .github/workflows, with git or the API", |
| 341 | 349 | | Scope::ChecksRead => "Read commits' statuses, check runs, check suites and annotations", |
| 342 | 350 | | Scope::ChecksWrite => "Report statuses and check runs on commits, and ask for checks to run again", |
| 343 | 351 | | Scope::DeploymentsRead => "See deployments, their statuses and environments", |
| ⋯ |
| 512 | 520 | | /// token made a request. Absent where whoever resolved it did not say. |
| 513 | 521 | | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 514 | 522 | | pub name: Option<String>, |
| 523 | + | /// Set on a fine-grained personal access token: whose resources it |
| 524 | + | /// reaches, and which of their repositories. Absent on a classic token, |
| 525 | + | /// which reaches whatever its owner can. |
| 526 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 527 | + | pub fine_grained: Option<FineGrainedReach>, |
| 528 | + | /// Set on a workspace's own token that an owner gave Admin when making |
| 529 | + | /// it. Without it a workspace's token has Write on the workspace's |
| 530 | + | /// repositories, as a member would (see [`crate::access`]). |
| 531 | + | #[serde(default, skip_serializing_if = "std::ops::Not::not")] |
| 532 | + | pub admin: bool, |
| 533 | + | /// Set on what a repository's deploy key resolves to: the key's id. Its |
| 534 | + | /// `repo` is the one repository it reaches. |
| 535 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 536 | + | pub deploy_key: Option<String>, |
| 537 | + | } |
| 538 | + | |
| 539 | + | /// Which of the resource owner's repositories a fine-grained token reaches. |
| 540 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 541 | + | #[serde(rename_all = "snake_case")] |
| 542 | + | pub enum RepositorySelection { |
| 543 | + | /// Every repository of the workspace, ones made later included. |
| 544 | + | #[default] |
| 545 | + | All, |
| 546 | + | /// The repositories chosen, by id. |
| 547 | + | Selected, |
| 548 | + | /// None of the workspace's private repositories: public repositories, |
| 549 | + | /// read-only, and the workspace's own settings its permissions allow. |
| 550 | + | Public, |
| 515 | 551 | | } |
| 516 | 552 | | |
| 553 | + | impl RepositorySelection { |
| 554 | + | pub fn as_str(self) -> &'static str { |
| 555 | + | match self { |
| 556 | + | RepositorySelection::All => "all", |
| 557 | + | RepositorySelection::Selected => "selected", |
| 558 | + | RepositorySelection::Public => "public", |
| 559 | + | } |
| 560 | + | } |
| 561 | + | |
| 562 | + | pub fn parse(text: &str) -> Option<RepositorySelection> { |
| 563 | + | match text.trim().to_ascii_lowercase().as_str() { |
| 564 | + | "all" => Some(RepositorySelection::All), |
| 565 | + | "selected" => Some(RepositorySelection::Selected), |
| 566 | + | "public" | "public_only" | "none" => Some(RepositorySelection::Public), |
| 567 | + | _ => None, |
| 568 | + | } |
| 569 | + | } |
| 570 | + | } |
| 571 | + | |
| 572 | + | /// What a fine-grained token reaches, as identity resolves it on each use. |
| 573 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 574 | + | pub struct FineGrainedReach { |
| 575 | + | /// The resource owner: the workspace whose repositories and settings it |
| 576 | + | /// reaches, by slug as it is now. Absent: the person's own account |
| 577 | + | /// only, with public repositories read-only. |
| 578 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 579 | + | pub workspace: Option<String>, |
| 580 | + | #[serde(default)] |
| 581 | + | pub repositories: RepositorySelection, |
| 582 | + | /// With [`RepositorySelection::Selected`]: the repositories' ids. |
| 583 | + | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
| 584 | + | pub repo_ids: Vec<String>, |
| 585 | + | } |
| 586 | + | |
| 587 | + | impl FineGrainedReach { |
| 588 | + | /// Whether it reaches the repository with this id in the workspace |
| 589 | + | /// `namespace` for more than what anyone may do with a public one. |
| 590 | + | pub fn covers(&self, repo_id: &str, namespace: &str) -> bool { |
| 591 | + | let Some(workspace) = self.workspace.as_deref() else { |
| 592 | + | return false; |
| 593 | + | }; |
| 594 | + | if !workspace.eq_ignore_ascii_case(namespace) { |
| 595 | + | return false; |
| 596 | + | } |
| 597 | + | match self.repositories { |
| 598 | + | RepositorySelection::All => true, |
| 599 | + | RepositorySelection::Selected => self.repo_ids.iter().any(|id| id == repo_id), |
| 600 | + | RepositorySelection::Public => false, |
| 601 | + | } |
| 602 | + | } |
| 603 | + | |
| 604 | + | /// Whether the workspace `slug` is its resource owner. |
| 605 | + | pub fn owned_by(&self, slug: &str) -> bool { |
| 606 | + | self.workspace.as_deref().is_some_and(|workspace| workspace.eq_ignore_ascii_case(slug)) |
| 607 | + | } |
| 608 | + | } |
| 609 | + | |
| 610 | + | /// Where workflow files live. Adding, changing or deleting a file under |
| 611 | + | /// one, with git or through g1t, needs [`Scope::WorkflowFilesWrite`] from a |
| 612 | + | /// token: what GitHub's `workflow` scope and `workflows` permission do. |
| 613 | + | pub const WORKFLOW_DIRS: [&str; 2] = [".g1t/workflows/", ".github/workflows/"]; |
| 614 | + | |
| 615 | + | /// Whether `path` is a workflow file, or a file in one's directory. |
| 616 | + | pub fn is_workflow_file(path: &str) -> bool { |
| 617 | + | let path = path.trim_start_matches('/'); |
| 618 | + | WORKFLOW_DIRS.iter().any(|dir| { |
| 619 | + | path.len() >= dir.len() && path.is_char_boundary(dir.len()) && path[..dir.len()].eq_ignore_ascii_case(dir) |
| 620 | + | }) || WORKFLOW_DIRS.iter().any(|dir| path.eq_ignore_ascii_case(dir.trim_end_matches('/'))) |
| 621 | + | } |
| 622 | + | |
| 623 | + | /// Whether a token may add, change or delete the files at `paths`: a |
| 624 | + | /// refusal naming the first workflow file it may not touch, else `None`. |
| 625 | + | /// A signed-in person (no token) is never refused here; their role decides. |
| 626 | + | pub fn decide_workflow_files<'a>(access: Option<&TokenAccess>, paths: impl IntoIterator<Item = &'a str>) -> Option<Decision> { |
| 627 | + | let access = access?; |
| 628 | + | if access.allows(Scope::WorkflowFilesWrite) && access.job.is_none() { |
| 629 | + | return None; |
| 630 | + | } |
| 631 | + | let path = paths.into_iter().find(|path| is_workflow_file(path))?; |
| 632 | + | let why = if access.job.is_some() { |
| 633 | + | "a workflow job's token can never add or change workflow files".to_owned() |
| 634 | + | } else if access.fine_grained.is_some() { |
| 635 | + | "it needs the Workflows permission (read and write), which maps to the workflow_files:write scope".to_owned() |
| 636 | + | } else { |
| 637 | + | format!("it needs the {} scope", Scope::WorkflowFilesWrite.as_str()) |
| 638 | + | }; |
| 639 | + | Some(Decision::deny( |
| 640 | + | "token:workflows", |
| 641 | + | format!("This access token cannot change the workflow file {path}: {why}."), |
| 642 | + | )) |
| 643 | + | } |
| 644 | + | |
| 517 | 645 | | /// The workflow job a token was made for. |
| 518 | 646 | | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 519 | 647 | | pub struct JobToken { |
| ⋯ |
| 557 | 685 | | Some(granted) => granted.iter().any(|held| held.includes(needed)), |
| 558 | 686 | | } |
| 559 | 687 | | } |
| 688 | + | |
| 689 | + | /// Whether it reaches the repository with this id in `namespace` for |
| 690 | + | /// more than reading a public one: every token but a fine-grained one |
| 691 | + | /// outside its resource owner or repository selection. Its owner's role |
| 692 | + | /// still decides; see [`crate::access`]. |
| 693 | + | pub fn covers_repo(&self, repo_id: &str, namespace: &str) -> bool { |
| 694 | + | self.fine_grained.as_ref().is_none_or(|reach| reach.covers(repo_id, namespace)) |
| 695 | + | } |
| 560 | 696 | | } |
| 561 | 697 | | |
| 562 | 698 | | /// Every operation of the API and MCP server, with the scope it needs. An |
| ⋯ |
| 959 | 1095 | | } |
| 960 | 1096 | | } |
| 961 | 1097 | | } |
| 1098 | + | // A fine-grained token only reads outside its resource owner: public |
| 1099 | + | // repositories, as anyone may. Inside it, its repository selection is |
| 1100 | + | // checked with its owner's role (`access::granted`). |
| 1101 | + | if let Some(reach) = &access.fine_grained |
| 1102 | + | && let Some(repo) = input["repo"].as_str() |
| 1103 | + | && !NO_SCOPE.contains(&operation) |
| 1104 | + | { |
| 1105 | + | let namespace = repo.split('/').next().unwrap_or_default(); |
| 1106 | + | let changes = needed(operation, input).iter().any(|scope| scope.level() != Level::Read); |
| 1107 | + | if changes && !reach.owned_by(namespace) { |
| 1108 | + | let owner = reach.workspace.as_deref().map_or_else(|| "your account".to_owned(), |workspace| format!("the workspace {workspace}")); |
| 1109 | + | return Decision::deny( |
| 1110 | + | "token:resource-owner", |
| 1111 | + | format!("This fine-grained token's resource owner is {owner}: it can only read public repositories elsewhere, and {repo} is not its owner's."), |
| 1112 | + | ); |
| 1113 | + | } |
| 1114 | + | } |
| 962 | 1115 | | if access.scopes.is_some() { |
| 963 | 1116 | | let known = NO_SCOPE.contains(&operation) || scope_for(operation).is_some(); |
| 964 | 1117 | | if !known { |