mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-15 23:32:46 +00:00
587 lines
20 KiB
Rust
587 lines
20 KiB
Rust
//! 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 fabro_types::settings::run::RunIntegrationsGithubSettings;
|
|
use futures::stream::{self, StreamExt as _};
|
|
|
|
use crate::{GitHubAppCredentials, HttpClient, InstallationLookup, InstallationToken};
|
|
|
|
/// Keep GitHub installation lookups bounded while avoiding one network round
|
|
/// trip at a time for large declared repository sets.
|
|
const INSTALLATION_LOOKUP_CONCURRENCY: usize = 4;
|
|
|
|
/// 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.
|
|
async fn resolve_shared_installation_with_jwt(
|
|
&self,
|
|
client: &impl HttpClient,
|
|
jwt: &str,
|
|
base_url: &str,
|
|
) -> anyhow::Result<u64> {
|
|
let targets: Vec<GitHubRepositorySlug> = self.targets().into_iter().cloned().collect();
|
|
let mut lookups = stream::iter(targets.into_iter().map(|slug| async move {
|
|
let lookup =
|
|
crate::lookup_installation(client, jwt, base_url, slug.owner(), slug.repo())
|
|
.await
|
|
.with_context(|| format!("looking up the GitHub App installation for {slug}"));
|
|
(slug, lookup)
|
|
}))
|
|
.buffered(INSTALLATION_LOOKUP_CONCURRENCY);
|
|
|
|
let mut shared: Option<(u64, GitHubRepositorySlug)> = None;
|
|
while let Some((slug, lookup)) = lookups.next().await {
|
|
let lookup = lookup?;
|
|
let id = match lookup {
|
|
InstallationLookup::Found(id) => id,
|
|
InstallationLookup::NotFound => bail!(
|
|
"the GitHub App installation cannot see repository {slug}; add it to the \
|
|
installation's repository access"
|
|
),
|
|
InstallationLookup::Failed(status) => bail!(
|
|
"unexpected status {status} looking up the GitHub App installation for {slug}"
|
|
),
|
|
};
|
|
match &shared {
|
|
None => shared = Some((id, slug)),
|
|
Some((shared_id, first)) if *shared_id != id => bail!(
|
|
"repository {slug} belongs to GitHub App installation {id} but {first} \
|
|
belongs to installation {shared_id}; all repositories must share one \
|
|
installation"
|
|
),
|
|
Some(_) => {}
|
|
}
|
|
}
|
|
let (id, _) = shared.expect("the effective repository set always contains the primary");
|
|
Ok(id)
|
|
}
|
|
|
|
/// Resolve every target to one installation, then mint one token scoped
|
|
/// to this exact repository set without looking up the primary twice.
|
|
pub(crate) async fn mint_installation_token(
|
|
&self,
|
|
creds: &GitHubAppCredentials,
|
|
client: &impl HttpClient,
|
|
base_url: &str,
|
|
) -> anyhow::Result<InstallationToken> {
|
|
let jwt = crate::sign_app_jwt(&creds.app_id, &creds.private_key_pem)?;
|
|
let installation_id = self
|
|
.resolve_shared_installation_with_jwt(client, &jwt, base_url)
|
|
.await
|
|
.context("resolving the shared GitHub App installation")?;
|
|
crate::mint_installation_token_for_id_with_jwt(
|
|
client,
|
|
&jwt,
|
|
installation_id,
|
|
&self.repository_names(),
|
|
base_url,
|
|
self.permissions_json()?,
|
|
)
|
|
.await
|
|
}
|
|
}
|
|
|
|
/// 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 !RunIntegrationsGithubSettings::contents_permission_allows_repository_access(contents) {
|
|
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_with_jwt(
|
|
&mock,
|
|
&crate::sign_app_jwt(&creds.app_id, &creds.private_key_pem).unwrap(),
|
|
"",
|
|
)
|
|
.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_with_jwt(
|
|
&mock,
|
|
&crate::sign_app_jwt(&creds.app_id, &creds.private_key_pem).unwrap(),
|
|
"",
|
|
)
|
|
.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_with_jwt(
|
|
&mock,
|
|
&crate::sign_app_jwt(&creds.app_id, &creds.private_key_pem).unwrap(),
|
|
"",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(id, 7);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn access_mint_reuses_the_resolved_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": 7}"#,
|
|
)
|
|
.on(
|
|
HttpMethod::Post,
|
|
"/app/installations/7/access_tokens",
|
|
201,
|
|
r#"{"token":"scoped","expires_at":"2099-01-01T00:00:00Z"}"#,
|
|
)
|
|
.with_req_body(
|
|
r#"{"repositories":["fabro","keystone"],"permissions":{"contents":"read"}}"#,
|
|
);
|
|
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 token = access
|
|
.mint_installation_token(&creds, &mock, "")
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(token.token, "scoped");
|
|
assert_eq!(
|
|
mock.request_count(),
|
|
3,
|
|
"each repository should be looked up once before the mint"
|
|
);
|
|
}
|
|
}
|