diff --git a/lib/components/fabro-github/src/access.rs b/lib/components/fabro-github/src/access.rs new file mode 100644 index 000000000..6ca1eb2aa --- /dev/null +++ b/lib/components/fabro-github/src/access.rs @@ -0,0 +1,505 @@ +//! The validated effective repository set for one run's GitHub access. +//! +//! [`GitHubRepositoryAccess`] is the single value both server preflight and +//! workflow initialization construct from the run origin, the declared +//! additional repositories, and the resolved shared permissions — so the two +//! paths cannot disagree about which repositories a run's `GITHUB_TOKEN` +//! covers. It carries no token or key material. + +use std::collections::{BTreeSet, HashMap}; + +use anyhow::{Context as _, bail}; +use fabro_types::GitHubRepositorySlug; + +use crate::{GitHubAppCredentials, HttpClient, HttpMethod}; + +/// The validated effective repository set for a run: the primary origin +/// repository plus zero or more distinct additional repositories, all with +/// one shared owner, and the shared permission map that scopes the token. +/// +/// Secret-free by construction: `Debug` may render everywhere the run +/// pipeline logs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitHubRepositoryAccess { + primary: GitHubRepositorySlug, + /// Sorted, deduplicated, primary excluded. + additional: Vec, + permissions: HashMap, +} + +impl GitHubRepositoryAccess { + /// Build the effective access request. + /// + /// Returns `Ok(None)` when no origin URL is available and no additional + /// repositories are declared — the legacy "nothing to scope" state whose + /// handling stays with the caller. Every declared-additional invariant is + /// enforced here: + /// + /// - a declared additional set requires a GitHub origin, + /// - the additional set cannot contain the primary repository, + /// - every additional repository shares the primary's owner + /// (case-insensitive) because one App installation covers one account, + /// - a declared additional set requires interpolated permissions with + /// `contents = "read"` or `contents = "write"`. + pub fn new( + origin_url: Option<&str>, + additional_repositories: &BTreeSet, + permissions: HashMap, + ) -> anyhow::Result> { + let origin_url = origin_url.map(str::trim).filter(|url| !url.is_empty()); + let Some(origin_url) = origin_url else { + if additional_repositories.is_empty() { + return Ok(None); + } + bail!( + "run.integrations.github.additional_repositories requires a GitHub run origin; \ + this run has no repository origin URL" + ); + }; + + let normalized = crate::normalize_repo_origin_url(origin_url); + let (owner, repo) = crate::parse_github_owner_repo(&normalized) + .context("parsing GitHub origin for repository access")?; + let Some(primary) = GitHubRepositorySlug::try_new(&format!("{owner}/{repo}")) else { + bail!("run origin does not name a valid GitHub `owner/repository`: {owner}/{repo}"); + }; + + if !additional_repositories.is_empty() { + validate_additional_permissions(&permissions)?; + } + + let mut additional = Vec::with_capacity(additional_repositories.len()); + for slug in additional_repositories { + if *slug == primary { + bail!( + "run.integrations.github.additional_repositories must not repeat the run \ + origin repository `{primary}` — the origin is always included" + ); + } + if !slug.same_owner(&primary) { + bail!( + "additional repository `{slug}` has owner `{}` but the run origin `{primary}` \ + has owner `{}`; all repositories must share one owner because one GitHub App \ + installation covers one account", + slug.owner(), + primary.owner() + ); + } + additional.push(slug.clone()); + } + + Ok(Some(Self { + primary, + additional, + permissions, + })) + } + + #[must_use] + pub fn primary(&self) -> &GitHubRepositorySlug { + &self.primary + } + + /// Every repository in the effective set, primary first, then the + /// additional repositories in their deterministic sorted order. + #[must_use] + pub fn targets(&self) -> Vec<&GitHubRepositorySlug> { + std::iter::once(&self.primary) + .chain(self.additional.iter()) + .collect() + } + + /// Project each validated slug to its repository-name component for the + /// installation-token mint request, which accepts names within the + /// selected installation. Every target shares the primary's owner, so the + /// projection loses nothing. + #[must_use] + pub fn repository_names(&self) -> Vec { + self.targets() + .into_iter() + .map(|slug| slug.repo().to_string()) + .collect() + } + + #[must_use] + pub fn owner(&self) -> &str { + self.primary.owner() + } + + #[must_use] + pub fn permissions(&self) -> &HashMap { + &self.permissions + } + + pub fn permissions_json(&self) -> anyhow::Result { + serde_json::to_value(&self.permissions).context("serializing GitHub permissions") + } + + #[must_use] + pub fn has_additional_repositories(&self) -> bool { + !self.additional.is_empty() + } + + /// Resolve every target's App installation and require one shared + /// installation ID, so a repository the App cannot see — or one that + /// resolves to a different installation — is named before any token is + /// minted. Targets are checked in deterministic primary-first order. + pub async fn resolve_shared_installation( + &self, + creds: &GitHubAppCredentials, + client: &impl HttpClient, + base_url: &str, + ) -> anyhow::Result { + #[derive(serde::Deserialize)] + struct Installation { + id: u64, + } + + let jwt = crate::sign_app_jwt(&creds.app_id, &creds.private_key_pem)?; + let auth = format!("Bearer {jwt}"); + let mut shared: Option<(u64, &GitHubRepositorySlug)> = None; + for slug in self.targets() { + let endpoint = format!( + "{base_url}/repos/{}/{}/installation", + slug.owner(), + slug.repo() + ); + let response = client + .request( + HttpMethod::Get, + &endpoint, + &crate::github_headers(&auth), + None, + ) + .await + .with_context(|| format!("looking up the GitHub App installation for {slug}"))?; + match response.status { + 200 => {} + 404 => bail!( + "the GitHub App installation cannot see repository {slug}; add it to the \ + installation's repository access" + ), + status => bail!( + "unexpected status {status} looking up the GitHub App installation for {slug}" + ), + } + let installation: Installation = response + .json() + .with_context(|| format!("parsing the installation response for {slug}"))?; + match shared { + None => shared = Some((installation.id, slug)), + Some((id, first)) if id != installation.id => bail!( + "repository {slug} belongs to GitHub App installation {} but {first} belongs \ + to installation {id}; all repositories must share one installation", + installation.id + ), + Some(_) => {} + } + } + let (id, _) = shared.expect("the effective repository set always contains the primary"); + Ok(id) + } +} + +/// A non-empty additional set needs a token that can reach repository +/// contents. Configuration resolution already checked literal values; this +/// is the runtime re-check after `{{ vars.* }}` interpolation. +fn validate_additional_permissions(permissions: &HashMap) -> anyhow::Result<()> { + let Some(contents) = permissions.get("contents") else { + bail!( + "run.integrations.github.additional_repositories requires the `contents` permission \ + (`read` or `write`)" + ); + }; + if contents != "read" && contents != "write" { + bail!( + "run.integrations.github.additional_repositories requires `contents = \"read\"` or \ + `contents = \"write\"`, got `{contents}`" + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn slugs(values: &[&str]) -> BTreeSet { + values + .iter() + .map(|value| value.parse().expect("test slug should parse")) + .collect() + } + + fn contents_read() -> HashMap { + HashMap::from([("contents".to_string(), "read".to_string())]) + } + + fn access( + origin: &str, + additional: &[&str], + permissions: HashMap, + ) -> anyhow::Result> { + GitHubRepositoryAccess::new(Some(origin), &slugs(additional), permissions) + } + + #[test] + fn https_and_both_ssh_origin_forms_normalize_to_the_same_primary() { + let origins = [ + "https://github.com/fabro-sh/fabro.git", + "git@github.com:fabro-sh/fabro.git", + "ssh://git@github.com/fabro-sh/fabro.git", + "https://github.com/fabro-sh/fabro", + ]; + for origin in origins { + let access = access(origin, &[], HashMap::new()) + .expect(origin) + .expect("origin should produce an access value"); + assert_eq!(access.primary().to_string(), "fabro-sh/fabro", "{origin}"); + } + } + + #[test] + fn no_origin_and_no_additional_repositories_is_none() { + let access = GitHubRepositoryAccess::new(None, &BTreeSet::new(), HashMap::new()).unwrap(); + assert!(access.is_none()); + + let blank = + GitHubRepositoryAccess::new(Some(" "), &BTreeSet::new(), HashMap::new()).unwrap(); + assert!(blank.is_none()); + } + + #[test] + fn additional_repositories_require_an_origin() { + let err = + GitHubRepositoryAccess::new(None, &slugs(&["fabro-sh/keystone"]), contents_read()) + .unwrap_err(); + assert!( + err.to_string().contains("requires a GitHub run origin"), + "{err:#}" + ); + } + + #[test] + fn additional_repositories_require_a_github_origin() { + let err = access( + "https://gitlab.com/fabro-sh/fabro", + &["fabro-sh/keystone"], + contents_read(), + ) + .unwrap_err(); + assert!(err.to_string().contains("repository access"), "{err:#}"); + } + + #[test] + fn rejects_primary_duplication_regardless_of_url_spelling_or_case() { + let origins = [ + "https://github.com/Fabro-SH/Fabro.git", + "git@github.com:fabro-sh/fabro.git", + "ssh://git@github.com/fabro-sh/fabro", + ]; + for origin in origins { + let err = access(origin, &["fabro-sh/FABRO"], contents_read()).unwrap_err(); + assert!( + err.to_string().contains("must not repeat the run origin"), + "{origin}: {err:#}" + ); + } + } + + #[test] + fn rejects_an_additional_repository_with_a_different_owner() { + let err = access( + "https://github.com/fabro-sh/fabro", + &["lithoscomputer/conveyor"], + contents_read(), + ) + .unwrap_err(); + let message = err.to_string(); + assert!(message.contains("lithoscomputer/conveyor"), "{message}"); + assert!(message.contains("share one owner"), "{message}"); + } + + #[test] + fn rejects_a_declared_set_without_a_contents_permission() { + let missing = access( + "https://github.com/fabro-sh/fabro", + &["fabro-sh/keystone"], + HashMap::new(), + ) + .unwrap_err(); + assert!( + missing.to_string().contains("`contents` permission"), + "{missing:#}" + ); + + let wrong_level = access( + "https://github.com/fabro-sh/fabro", + &["fabro-sh/keystone"], + HashMap::from([("contents".to_string(), "admin".to_string())]), + ) + .unwrap_err(); + assert!( + wrong_level.to_string().contains("got `admin`"), + "{wrong_level:#}" + ); + } + + #[test] + fn targets_retain_every_full_slug_exactly_once_primary_first() { + let access = access( + "git@github.com:fabro-sh/fabro.git", + &["fabro-sh/keystone", "fabro-sh/arc"], + contents_read(), + ) + .unwrap() + .unwrap(); + + let targets: Vec = access.targets().iter().map(ToString::to_string).collect(); + assert_eq!(targets, vec![ + "fabro-sh/fabro", + "fabro-sh/arc", + "fabro-sh/keystone", + ]); + assert_eq!(access.repository_names(), vec!["fabro", "arc", "keystone"]); + assert_eq!(access.owner(), "fabro-sh"); + assert!(access.has_additional_repositories()); + } + + #[test] + fn debug_output_contains_only_repositories_and_permissions() { + let access = access( + "https://github.com/fabro-sh/fabro", + &["fabro-sh/arc"], + contents_read(), + ) + .unwrap() + .unwrap(); + + let rendered = format!("{access:?}"); + assert!(rendered.contains("fabro-sh"), "{rendered}"); + assert!(rendered.contains("contents"), "{rendered}"); + // The value carries no token or key material by construction; its + // fields are exactly the repository slugs and the permission map. + assert!(!rendered.to_lowercase().contains("token"), "{rendered}"); + assert!(!rendered.to_lowercase().contains("key"), "{rendered}"); + } + + #[tokio::test] + async fn resolve_shared_installation_names_the_invisible_repository() { + use crate::HttpMethod; + use crate::tests_mock::{MockHttpClient, test_rsa_key}; + + let mock = MockHttpClient::new() + .on( + HttpMethod::Get, + "/repos/fabro-sh/fabro/installation", + 200, + r#"{"id": 7}"#, + ) + .on( + HttpMethod::Get, + "/repos/fabro-sh/keystone/installation", + 404, + "{}", + ); + let creds = GitHubAppCredentials { + app_id: "test".to_string(), + private_key_pem: test_rsa_key().to_string(), + slug: None, + }; + let access = access( + "https://github.com/fabro-sh/fabro", + &["fabro-sh/keystone"], + contents_read(), + ) + .unwrap() + .unwrap(); + + let err = access + .resolve_shared_installation(&creds, &mock, "") + .await + .unwrap_err(); + let message = err.to_string(); + assert!(message.contains("fabro-sh/keystone"), "{message}"); + assert!(message.contains("cannot see"), "{message}"); + } + + #[tokio::test] + async fn resolve_shared_installation_requires_one_installation_id() { + use crate::HttpMethod; + use crate::tests_mock::{MockHttpClient, test_rsa_key}; + + let mock = MockHttpClient::new() + .on( + HttpMethod::Get, + "/repos/fabro-sh/fabro/installation", + 200, + r#"{"id": 7}"#, + ) + .on( + HttpMethod::Get, + "/repos/fabro-sh/keystone/installation", + 200, + r#"{"id": 8}"#, + ); + let creds = GitHubAppCredentials { + app_id: "test".to_string(), + private_key_pem: test_rsa_key().to_string(), + slug: None, + }; + let access = access( + "https://github.com/fabro-sh/fabro", + &["fabro-sh/keystone"], + contents_read(), + ) + .unwrap() + .unwrap(); + + let err = access + .resolve_shared_installation(&creds, &mock, "") + .await + .unwrap_err(); + let message = err.to_string(); + assert!(message.contains("installation 8"), "{message}"); + assert!(message.contains("fabro-sh/keystone"), "{message}"); + } + + #[tokio::test] + async fn resolve_shared_installation_returns_the_shared_id() { + use crate::HttpMethod; + use crate::tests_mock::{MockHttpClient, test_rsa_key}; + + let mock = MockHttpClient::new() + .on( + HttpMethod::Get, + "/repos/fabro-sh/fabro/installation", + 200, + r#"{"id": 7}"#, + ) + .on( + HttpMethod::Get, + "/repos/fabro-sh/keystone/installation", + 200, + r#"{"id": 7}"#, + ); + let creds = GitHubAppCredentials { + app_id: "test".to_string(), + private_key_pem: test_rsa_key().to_string(), + slug: None, + }; + let access = access( + "https://github.com/fabro-sh/fabro", + &["fabro-sh/keystone"], + contents_read(), + ) + .unwrap() + .unwrap(); + + let id = access + .resolve_shared_installation(&creds, &mock, "") + .await + .unwrap(); + assert_eq!(id, 7); + } +} diff --git a/lib/components/fabro-github/src/lib.rs b/lib/components/fabro-github/src/lib.rs index a38c6c8ce..71d586213 100644 --- a/lib/components/fabro-github/src/lib.rs +++ b/lib/components/fabro-github/src/lib.rs @@ -9,10 +9,15 @@ use fabro_types::settings::run::MergeStrategy; use serde::Deserialize; use tokio::process::Command; +pub mod access; pub mod token_source; #[cfg(any(test, feature = "test-support"))] pub mod test_support; +#[cfg(test)] +pub(crate) mod tests_mock; + +pub use access::GitHubRepositoryAccess; pub const GITHUB_API_BASE_URL: &str = "https://api.github.com"; @@ -151,6 +156,29 @@ impl GitHubAppCredentials { base_url: &str, permissions: serde_json::Value, install_url: Option<&str>, + ) -> anyhow::Result { + self.mint_installation_token_for_repositories( + client, + owner, + &[repo.to_string()], + base_url, + permissions, + install_url, + ) + .await + } + + /// Mint one installation token scoped to every repository in + /// `repository_names` (names within `owner`'s installation, primary + /// first) with the shared `permissions`. + pub async fn mint_installation_token_for_repositories( + &self, + client: &impl HttpClient, + owner: &str, + repository_names: &[String], + base_url: &str, + permissions: serde_json::Value, + install_url: Option<&str>, ) -> anyhow::Result { let jwt = sign_app_jwt(&self.app_id, &self.private_key_pem)?; let default_install_url = self.installation_url(owner); @@ -159,7 +187,7 @@ impl GitHubAppCredentials { client, &jwt, owner, - repo, + repository_names, base_url, permissions, install_url, @@ -475,16 +503,24 @@ pub async fn create_installation_access_token_with_permissions_and_install_url( permissions: serde_json::Value, install_url: Option<&str>, ) -> anyhow::Result { - mint_installation_token_with_jwt(client, jwt, owner, repo, base_url, permissions, install_url) - .await - .map(|token| token.token) + mint_installation_token_with_jwt( + client, + jwt, + owner, + &[repo.to_string()], + base_url, + permissions, + install_url, + ) + .await + .map(|token| token.token) } async fn mint_installation_token_with_jwt( client: &impl HttpClient, jwt: &str, owner: &str, - repo: &str, + repos: &[String], base_url: &str, permissions: serde_json::Value, install_url: Option<&str>, @@ -500,8 +536,15 @@ async fn mint_installation_token_with_jwt( expires_at: DateTime, } - // Step 1: Find the installation for this repo - let installation_endpoint = format!("{base_url}/repos/{owner}/{repo}/installation"); + let Some(primary_repo) = repos.first() else { + bail!("installation token mint requires at least one repository"); + }; + + // Step 1: Find the installation via the primary repository. Multi- + // repository callers resolve every repository's installation up front + // (`GitHubRepositoryAccess::resolve_shared_installation`), so the + // primary stands for the whole set here. + let installation_endpoint = format!("{base_url}/repos/{owner}/{primary_repo}/installation"); let auth = format!("Bearer {jwt}"); let resp = client .request( @@ -555,7 +598,7 @@ async fn mint_installation_token_with_jwt( installation.id ); let body = serde_json::json!({ - "repositories": [repo], + "repositories": repos, "permissions": permissions, }); @@ -573,8 +616,9 @@ async fn mint_installation_token_with_jwt( 201 => {} 422 => { bail!( - "GitHub App does not have access to repository {repo}. \ - Update the installation's repository permissions to include it." + "GitHub App does not have access to every requested repository ({}). \ + Update the installation's repository permissions to include them.", + repos.join(", ") ); } 401 => { @@ -1715,7 +1759,7 @@ mod tests { // ----------------------------------------------------------------------- fn test_rsa_key() -> &'static str { - include_str!("testdata/rsa_private.pem") + tests_mock::test_rsa_key() } #[test] @@ -1769,89 +1813,7 @@ mod tests { // MockHttpClient // ----------------------------------------------------------------------- - struct MockRoute { - method: HttpMethod, - path: String, - status: u16, - response_body: String, - assert_header: Option<(String, MockHeaderCheck)>, - assert_body_json: Option, - } - - enum MockHeaderCheck { - Equals(String), - } - - struct MockHttpClient { - routes: Vec, - } - - impl MockHttpClient { - fn new() -> Self { - Self { routes: vec![] } - } - - fn on(mut self, method: HttpMethod, path: &str, status: u16, body: &str) -> Self { - self.routes.push(MockRoute { - method, - path: path.to_string(), - status, - response_body: body.to_string(), - assert_header: None, - assert_body_json: None, - }); - self - } - - fn with_req_header(mut self, name: &str, value: &str) -> Self { - self.routes.last_mut().unwrap().assert_header = - Some((name.to_string(), MockHeaderCheck::Equals(value.to_string()))); - self - } - - fn with_req_body(mut self, json_str: &str) -> Self { - self.routes.last_mut().unwrap().assert_body_json = - Some(serde_json::from_str(json_str).unwrap()); - self - } - } - - impl HttpClient for MockHttpClient { - async fn request( - &self, - method: HttpMethod, - url: &str, - headers: &[(&str, &str)], - body: Option<&serde_json::Value>, - ) -> anyhow::Result { - for route in &self.routes { - if method == route.method && url.ends_with(&route.path) { - if let Some((name, MockHeaderCheck::Equals(expected))) = &route.assert_header { - let (_, v) = headers - .iter() - .find(|(k, _)| *k == name.as_str()) - .unwrap_or_else(|| { - panic!("Expected header '{name}' not found in request to {url}") - }); - assert_eq!(*v, expected.as_str(), "Header '{name}' mismatch for {url}"); - } - if let Some(expected_body) = &route.assert_body_json { - let actual = body.expect("Expected request body"); - assert_eq!(actual, expected_body, "Request body mismatch for {url}"); - } - return Ok(HttpResponse::new(route.status, route.response_body.clone())); - } - } - panic!( - "No mock route for {:?} {url}\nRegistered routes: {:?}", - method, - self.routes - .iter() - .map(|r| format!("{:?} {}", r.method, r.path)) - .collect::>() - ); - } - } + use crate::tests_mock::{self, MockHttpClient}; // ----------------------------------------------------------------------- // create_installation_access_token — success @@ -1901,6 +1863,62 @@ mod tests { ); } + /// The multi-repository mint sends one request listing every projected + /// repository name exactly once, primary first, with the shared + /// permissions; the installation lookup uses the primary repository. + #[tokio::test] + async fn multi_repository_mint_lists_every_repository_name_once() { + let access = GitHubRepositoryAccess::new( + Some("git@github.com:owner/repo.git"), + &[ + "owner/keystone".parse().unwrap(), + "owner/arc".parse().unwrap(), + ] + .into_iter() + .collect(), + std::collections::HashMap::from([("contents".to_string(), "read".to_string())]), + ) + .unwrap() + .expect("origin should produce an access value"); + + let mock = MockHttpClient::new() + .on( + HttpMethod::Get, + "/repos/owner/repo/installation", + 200, + r#"{"id": 123}"#, + ) + .on( + HttpMethod::Post, + "/app/installations/123/access_tokens", + 201, + r#"{"token": "ghs_multi", "expires_at": "2026-01-01T12:00:00Z"}"#, + ) + .with_req_body( + r#"{"permissions":{"contents":"read"},"repositories":["repo","arc","keystone"]}"#, + ); + + let creds = GitHubAppCredentials { + app_id: "test".to_string(), + private_key_pem: test_rsa_key().to_string(), + slug: None, + }; + + let token = creds + .mint_installation_token_for_repositories( + &mock, + access.owner(), + &access.repository_names(), + "", + access.permissions_json().unwrap(), + None, + ) + .await + .unwrap(); + + assert_eq!(token.token, "ghs_multi"); + } + #[tokio::test] async fn create_iat_requests_only_contents_write() { let mock = MockHttpClient::new() diff --git a/lib/components/fabro-github/src/tests_mock.rs b/lib/components/fabro-github/src/tests_mock.rs new file mode 100644 index 000000000..da6d7920e --- /dev/null +++ b/lib/components/fabro-github/src/tests_mock.rs @@ -0,0 +1,93 @@ +//! Crate-internal test doubles shared by the `lib.rs` and `access` test +//! modules: a scripted [`HttpClient`] and a throwaway RSA key for JWT +//! signing. + +use crate::{HttpClient, HttpMethod, HttpResponse}; + +pub(crate) fn test_rsa_key() -> &'static str { + include_str!("testdata/rsa_private.pem") +} + +pub(crate) struct MockRoute { + method: HttpMethod, + path: String, + status: u16, + response_body: String, + assert_header: Option<(String, MockHeaderCheck)>, + assert_body_json: Option, +} + +pub(crate) enum MockHeaderCheck { + Equals(String), +} + +pub(crate) struct MockHttpClient { + routes: Vec, +} + +impl MockHttpClient { + pub(crate) fn new() -> Self { + Self { routes: vec![] } + } + + pub(crate) fn on(mut self, method: HttpMethod, path: &str, status: u16, body: &str) -> Self { + self.routes.push(MockRoute { + method, + path: path.to_string(), + status, + response_body: body.to_string(), + assert_header: None, + assert_body_json: None, + }); + self + } + + pub(crate) fn with_req_header(mut self, name: &str, value: &str) -> Self { + self.routes.last_mut().unwrap().assert_header = + Some((name.to_string(), MockHeaderCheck::Equals(value.to_string()))); + self + } + + pub(crate) fn with_req_body(mut self, json_str: &str) -> Self { + self.routes.last_mut().unwrap().assert_body_json = + Some(serde_json::from_str(json_str).unwrap()); + self + } +} + +impl HttpClient for MockHttpClient { + async fn request( + &self, + method: HttpMethod, + url: &str, + headers: &[(&str, &str)], + body: Option<&serde_json::Value>, + ) -> anyhow::Result { + for route in &self.routes { + if method == route.method && url.ends_with(&route.path) { + if let Some((name, MockHeaderCheck::Equals(expected))) = &route.assert_header { + let (_, v) = headers + .iter() + .find(|(k, _)| *k == name.as_str()) + .unwrap_or_else(|| { + panic!("Expected header '{name}' not found in request to {url}") + }); + assert_eq!(*v, expected.as_str(), "Header '{name}' mismatch for {url}"); + } + if let Some(expected_body) = &route.assert_body_json { + let actual = body.expect("Expected request body"); + assert_eq!(actual, expected_body, "Request body mismatch for {url}"); + } + return Ok(HttpResponse::new(route.status, route.response_body.clone())); + } + } + panic!( + "No mock route for {:?} {url}\nRegistered routes: {:?}", + method, + self.routes + .iter() + .map(|r| format!("{:?} {}", r.method, r.path)) + .collect::>() + ); + } +} diff --git a/lib/components/fabro-github/src/token_source.rs b/lib/components/fabro-github/src/token_source.rs index 276c5f9b8..d8c16228c 100644 --- a/lib/components/fabro-github/src/token_source.rs +++ b/lib/components/fabro-github/src/token_source.rs @@ -15,7 +15,7 @@ use std::fmt; use std::sync::Arc; use std::time::Duration; -use anyhow::Context as _; +use anyhow::{Context as _, bail}; use chrono::{DateTime, Utc}; use tokio::sync::Mutex; @@ -146,12 +146,14 @@ pub(crate) trait InstallationTokenMinter: Send + Sync { async fn mint(&self) -> anyhow::Result; } -/// Real minter backed by GitHub App credentials. +/// Real minter backed by GitHub App credentials. `repos` lists repository +/// names within the owner's installation, primary first; the minted token is +/// scoped to exactly that set. struct AppTokenMinter { creds: GitHubAppCredentials, http: fabro_http::HttpClient, owner: String, - repo: String, + repos: Vec, base_url: String, permissions: serde_json::Value, } @@ -160,10 +162,10 @@ struct AppTokenMinter { impl InstallationTokenMinter for AppTokenMinter { async fn mint(&self) -> anyhow::Result { self.creds - .mint_installation_token( + .mint_installation_token_for_repositories( &self.http, &self.owner, - &self.repo, + &self.repos, &self.base_url, self.permissions.clone(), None, @@ -243,7 +245,38 @@ impl InstallationTokenSource { repo: String, permissions: serde_json::Value, ) -> anyhow::Result> { - let repo_display = format!("{owner}/{repo}"); + Self::for_repositories(creds, owner, vec![repo], permissions) + } + + /// Build a source for a validated effective repository set. Minted + /// tokens are scoped to every repository in the set with the shared + /// permissions; caching, refresh margin, and single-flight behavior are + /// identical to the single-repository source. + pub fn for_access( + creds: &GitHubCredentials, + access: &crate::GitHubRepositoryAccess, + ) -> anyhow::Result> { + Self::for_repositories( + creds, + access.owner().to_string(), + access.repository_names(), + access.permissions_json()?, + ) + } + + fn for_repositories( + creds: &GitHubCredentials, + owner: String, + repos: Vec, + permissions: serde_json::Value, + ) -> anyhow::Result> { + let repo_display = match repos.as_slice() { + [primary] => format!("{owner}/{primary}"), + [primary, additional @ ..] => { + format!("{owner}/{primary} (+{} additional)", additional.len()) + } + [] => bail!("token source requires at least one repository"), + }; let state = match creds { GitHubCredentials::Pat(token) => SourceState::Pat(SecretString::new(token.clone())), GitHubCredentials::Installation(token) => SourceState::Installation(token.clone()), @@ -256,7 +289,7 @@ impl InstallationTokenSource { creds: app.clone(), http, owner, - repo, + repos, base_url: crate::github_api_base_url(), permissions, }), @@ -502,6 +535,31 @@ mod tests { assert!(!source.mints_installation_tokens()); } + /// A source built from a validated multi-repository access value uses the + /// same state machine as the single-repository constructor: static + /// credentials pass through, and App credentials share the cache + /// machinery exercised by the `with_minter` tests below. + #[tokio::test] + async fn for_access_source_resolves_like_the_single_repository_source() { + let access = crate::GitHubRepositoryAccess::new( + Some("https://github.com/owner/repo.git"), + &["owner/keystone".parse().unwrap()].into_iter().collect(), + std::collections::HashMap::from([("contents".to_string(), "read".to_string())]), + ) + .unwrap() + .expect("origin should produce an access value"); + + let source = InstallationTokenSource::for_access( + &GitHubCredentials::Pat("ghp_pat".to_string()), + &access, + ) + .unwrap(); + + let resolved = source.resolve().await.unwrap(); + assert_eq!(resolved.token.expose(), "ghp_pat"); + assert!(resolved.snapshot.is_static()); + } + #[tokio::test] async fn static_installation_token_resolves_until_expiry() { let valid = InstallationTokenSource::for_origin(