mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
feat(github): mint one installation token for the effective repository set
Add `GitHubRepositoryAccess`, the secret-free validated value describing a run's effective GitHub repository set: the primary origin repository plus the declared additional repositories with the shared permission map. - The constructor normalizes HTTPS and both SSH origin spellings to one primary slug, rejects a missing or non-GitHub origin when additional repositories are declared, rejects primary duplication and cross-owner additional repositories, and re-checks that interpolated permissions carry `contents = "read"|"write"` — exposing targets in deterministic primary-first order. - `resolve_shared_installation` resolves every target's App installation with the App JWT and requires one shared installation ID, naming the repository the App cannot see before any mint. - The installation-token mint now accepts a repository-name list; the single-repository entry points delegate to it, and the request body lists every projected name with the shared permissions. - `InstallationTokenSource::for_access` builds a source over the access value; caching, refresh margin, and single-flight are unchanged. - The scripted `MockHttpClient` and test RSA key move to a shared crate-internal `tests_mock` module. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f2047ad9a9
commit
7bfed23153
4 changed files with 775 additions and 101 deletions
505
lib/components/fabro-github/src/access.rs
Normal file
505
lib/components/fabro-github/src/access.rs
Normal file
|
|
@ -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<GitHubRepositorySlug>,
|
||||
permissions: HashMap<String, String>,
|
||||
}
|
||||
|
||||
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<GitHubRepositorySlug>,
|
||||
permissions: HashMap<String, String>,
|
||||
) -> anyhow::Result<Option<Self>> {
|
||||
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<String> {
|
||||
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<String, String> {
|
||||
&self.permissions
|
||||
}
|
||||
|
||||
pub fn permissions_json(&self) -> anyhow::Result<serde_json::Value> {
|
||||
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<u64> {
|
||||
#[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<String, String>) -> 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<GitHubRepositorySlug> {
|
||||
values
|
||||
.iter()
|
||||
.map(|value| value.parse().expect("test slug should parse"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn contents_read() -> HashMap<String, String> {
|
||||
HashMap::from([("contents".to_string(), "read".to_string())])
|
||||
}
|
||||
|
||||
fn access(
|
||||
origin: &str,
|
||||
additional: &[&str],
|
||||
permissions: HashMap<String, String>,
|
||||
) -> anyhow::Result<Option<GitHubRepositoryAccess>> {
|
||||
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<String> = 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<InstallationToken> {
|
||||
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<InstallationToken> {
|
||||
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<String> {
|
||||
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<Utc>,
|
||||
}
|
||||
|
||||
// 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<serde_json::Value>,
|
||||
}
|
||||
|
||||
enum MockHeaderCheck {
|
||||
Equals(String),
|
||||
}
|
||||
|
||||
struct MockHttpClient {
|
||||
routes: Vec<MockRoute>,
|
||||
}
|
||||
|
||||
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<HttpResponse> {
|
||||
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::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
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()
|
||||
|
|
|
|||
93
lib/components/fabro-github/src/tests_mock.rs
Normal file
93
lib/components/fabro-github/src/tests_mock.rs
Normal file
|
|
@ -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<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub(crate) enum MockHeaderCheck {
|
||||
Equals(String),
|
||||
}
|
||||
|
||||
pub(crate) struct MockHttpClient {
|
||||
routes: Vec<MockRoute>,
|
||||
}
|
||||
|
||||
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<HttpResponse> {
|
||||
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::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<InstallationToken>;
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
base_url: String,
|
||||
permissions: serde_json::Value,
|
||||
}
|
||||
|
|
@ -160,10 +162,10 @@ struct AppTokenMinter {
|
|||
impl InstallationTokenMinter for AppTokenMinter {
|
||||
async fn mint(&self) -> anyhow::Result<InstallationToken> {
|
||||
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<Arc<Self>> {
|
||||
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<Arc<Self>> {
|
||||
Self::for_repositories(
|
||||
creds,
|
||||
access.owner().to_string(),
|
||||
access.repository_names(),
|
||||
access.permissions_json()?,
|
||||
)
|
||||
}
|
||||
|
||||
fn for_repositories(
|
||||
creds: &GitHubCredentials,
|
||||
owner: String,
|
||||
repos: Vec<String>,
|
||||
permissions: serde_json::Value,
|
||||
) -> anyhow::Result<Arc<Self>> {
|
||||
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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue