Skip to content

Commit 62699a1

Browse files
committed
feat(provider): add a refresh strategy for github app installations
Signed-off-by: Gordon Sim <gsim@redhat.com>
1 parent 6c864ec commit 62699a1

29 files changed

Lines changed: 1984 additions & 373 deletions

File tree

‎CI.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,20 @@ The GitHub ruleset should require the `OpenShell / ...` statuses published by
3434
`Required CI Gates` plus the direct `OpenShell / Trivy Changes` result, not the
3535
push-triggered workflow jobs themselves.
3636

37+
The GitHub App sandbox regression is independently selectable:
38+
39+
```shell
40+
OPENSHELL_E2E_PODMAN_TEST=provider_github_app mise run e2e:podman
41+
```
42+
43+
It builds a tool image with `gh` and Git, then uses a local installation-token
44+
issuer and authenticated HTTPS API/Git fixture. It checks both clients across
45+
12 rotations, revocation after reconfiguration, and a fresh process using the
46+
replacement handle. No live GitHub app or installation is required. It needs
47+
the wrapper-managed gateway so it can temporarily trust the fixture CA; the
48+
original gateway configuration is restored afterward. The OAuth counterpart
49+
remains separately selectable as `provider_refresh_handles`.
50+
3751
## Informational security reports
3852

3953
Security workflow compute runs directly on GitHub-hosted runners instead of

‎architecture/build.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,12 @@ test identity and tools, with Python aligned to the host test runner for
108108
serialized callable compatibility. Default-image coverage retains the product
109109
image. Other compute-driver test lanes retain their existing workload fixtures.
110110

111+
The Podman E2E gateway pins both the supervisor and sandbox runtime images
112+
selected by its wrapper, so a local test cannot mix a checkout's supervisor
113+
with a registry-default sandbox runtime. GitHub App credential tests run as a
114+
separate target with a local HTTPS API and Git backend, ephemeral CA trust, and
115+
real workload clients; they require no live GitHub credentials.
116+
111117
The Docker image pipeline is a two-step flow: build the Rust binary natively
112118
for the target architecture, then assemble the container image from the
113119
prebuilt binary. The gateway, sandbox, and supervisor images use distinct

‎architecture/gateway.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,19 @@ credential storage for defense in depth. Multi-replica deployments can use that
788788
default with a shared database and shared key-encryption key, or opt into an
789789
external backend such as Vault or Kubernetes Secrets.
790790

791+
GitHub App installation refresh signs an app JWT at the gateway and exchanges it
792+
for an installation token using a profile-owned endpoint. Each provider pins one
793+
installation and explicit repository IDs and permissions in its refresh material;
794+
there is no workload-selected scope or installation-wide default. The app key is
795+
secret material, and only the installation token enters the existing credential
796+
distribution path. Refresh honors the issuer expiry, caps local use at one hour,
797+
and preserves the existing authorization epoch during routine rotation. Explicit
798+
reconfiguration revokes the old workload handles. Network policy remains an
799+
independent constraint on token use. GitHub mint failures use gateway-owned
800+
recovery classifications without storing issuer-controlled error prose.
801+
Transport diagnostics classify typed timeout, TLS, and connection failures
802+
without exposing request material or raw error chains.
803+
791804
The Vault credential driver requires HTTPS for every non-loopback backend,
792805
never follows HTTP redirects, and keeps standard certificate hostname
793806
verification enabled. Operators can add private Vault trust roots with a PEM

‎crates/openshell-cli/src/commands/common.rs‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1180,13 +1180,15 @@ mod tests {
11801180
assert_eq!(prof[0].0, "GITHUB_TOKEN");
11811181

11821182
let sug = &prof[0].1;
1183-
assert_eq!(sug.len(), 2_usize);
1183+
assert_eq!(sug.len(), 3_usize);
11841184

11851185
assert_eq!(sug[0].provider_type, "copilot");
11861186
assert_eq!(sug[0].credential, "api_token");
11871187

11881188
assert_eq!(sug[1].provider_type, "github");
11891189
assert_eq!(sug[1].credential, "api_token");
1190+
assert_eq!(sug[2].provider_type, "github-app");
1191+
assert_eq!(sug[2].credential, "api_token");
11901192
}
11911193

11921194
#[test]
@@ -1198,13 +1200,15 @@ mod tests {
11981200
assert_eq!(prof[0].0, "gh_token");
11991201

12001202
let sug = &prof[0].1;
1201-
assert_eq!(sug.len(), 2_usize);
1203+
assert_eq!(sug.len(), 3_usize);
12021204

12031205
assert_eq!(sug[0].provider_type, "copilot");
12041206
assert_eq!(sug[0].credential, "api_token");
12051207

12061208
assert_eq!(sug[1].provider_type, "github");
12071209
assert_eq!(sug[1].credential, "api_token");
1210+
assert_eq!(sug[2].provider_type, "github-app");
1211+
assert_eq!(sug[2].credential, "api_token");
12081212
}
12091213

12101214
#[test]

‎crates/openshell-cli/src/commands/provider.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2063,6 +2063,7 @@ fn provider_refresh_strategy(strategy: &str) -> Result<ProviderCredentialRefresh
20632063
Ok(ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt)
20642064
}
20652065
"aws_sts_assume_role" => Ok(ProviderCredentialRefreshStrategy::AwsStsAssumeRole),
2066+
"github_app_installation" => Ok(ProviderCredentialRefreshStrategy::GithubAppInstallation),
20662067
_ => Err(miette!("unsupported provider refresh strategy: {strategy}")),
20672068
}
20682069
}
@@ -2114,6 +2115,7 @@ fn provider_refresh_strategy_name(strategy: ProviderCredentialRefreshStrategy) -
21142115
ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => "oauth2_client_credentials",
21152116
ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => "google_service_account_jwt",
21162117
ProviderCredentialRefreshStrategy::AwsStsAssumeRole => "aws_sts_assume_role",
2118+
ProviderCredentialRefreshStrategy::GithubAppInstallation => "github_app_installation",
21172119
ProviderCredentialRefreshStrategy::Unspecified => "unspecified",
21182120
}
21192121
}

‎crates/openshell-cli/src/main.rs‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -776,6 +776,7 @@ enum CliProviderRefreshStrategy {
776776
Oauth2ClientCredentials,
777777
GoogleServiceAccountJwt,
778778
AwsStsAssumeRole,
779+
GithubAppInstallation,
779780
}
780781

781782
impl CliProviderRefreshStrategy {
@@ -785,6 +786,7 @@ impl CliProviderRefreshStrategy {
785786
Self::Oauth2ClientCredentials => "oauth2_client_credentials",
786787
Self::GoogleServiceAccountJwt => "google_service_account_jwt",
787788
Self::AwsStsAssumeRole => "aws_sts_assume_role",
789+
Self::GithubAppInstallation => "github_app_installation",
788790
}
789791
}
790792
}
@@ -5625,6 +5627,39 @@ mod tests {
56255627
assert!(msg.contains("--from-gcloud-adc"));
56265628
}
56275629

5630+
#[test]
5631+
fn github_app_refresh_command_parses() {
5632+
let config = Cli::try_parse_from([
5633+
"openshell",
5634+
"provider",
5635+
"refresh",
5636+
"configure",
5637+
"repo-reader",
5638+
"--credential-key",
5639+
"GITHUB_TOKEN",
5640+
"--strategy",
5641+
"github-app-installation",
5642+
"--material",
5643+
"repository_ids=[42]",
5644+
"--material",
5645+
"permissions={\"contents\":\"read\"}",
5646+
"--secret-material-env",
5647+
"private_key=GITHUB_APP_PRIVATE_KEY",
5648+
])
5649+
.expect("GitHub App refresh configuration should parse");
5650+
assert!(matches!(
5651+
config.command,
5652+
Some(Commands::Provider {
5653+
command: Some(ProviderCommands::Refresh(
5654+
ProviderRefreshCommands::Configure {
5655+
strategy: CliProviderRefreshStrategy::GithubAppInstallation,
5656+
..
5657+
}
5658+
))
5659+
})
5660+
));
5661+
}
5662+
56285663
#[test]
56295664
fn provider_refresh_commands_parse() {
56305665
let status = Cli::try_parse_from([

‎crates/openshell-providers/src/profiles.rs‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1063,6 +1063,7 @@ pub fn is_gateway_mintable_strategy(strategy: ProviderCredentialRefreshStrategy)
10631063
| ProviderCredentialRefreshStrategy::Oauth2ClientCredentials
10641064
| ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt
10651065
| ProviderCredentialRefreshStrategy::AwsStsAssumeRole
1066+
| ProviderCredentialRefreshStrategy::GithubAppInstallation
10661067
)
10671068
}
10681069

@@ -1313,6 +1314,7 @@ pub fn provider_refresh_strategy_from_yaml(raw: &str) -> Option<ProviderCredenti
13131314
Some(ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt)
13141315
}
13151316
"aws_sts_assume_role" => Some(ProviderCredentialRefreshStrategy::AwsStsAssumeRole),
1317+
"github_app_installation" => Some(ProviderCredentialRefreshStrategy::GithubAppInstallation),
13161318
_ => None,
13171319
}
13181320
}
@@ -1328,6 +1330,7 @@ pub fn provider_refresh_strategy_to_yaml(
13281330
ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => "oauth2_client_credentials",
13291331
ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => "google_service_account_jwt",
13301332
ProviderCredentialRefreshStrategy::AwsStsAssumeRole => "aws_sts_assume_role",
1333+
ProviderCredentialRefreshStrategy::GithubAppInstallation => "github_app_installation",
13311334
ProviderCredentialRefreshStrategy::Unspecified => "unspecified",
13321335
}
13331336
}
@@ -2198,6 +2201,20 @@ pub fn validate_profile_set(
21982201
}
21992202

22002203
if let Some(refresh) = credential.refresh.as_ref() {
2204+
if refresh.strategy == ProviderCredentialRefreshStrategy::GithubAppInstallation
2205+
&& (!refresh
2206+
.token_url
2207+
.ends_with("/app/installations/{installation_id}/access_tokens")
2208+
|| refresh.token_url.matches("{installation_id}").count() != 1
2209+
|| !refresh.scopes.is_empty())
2210+
{
2211+
diagnostics.push(ProfileValidationDiagnostic::error(
2212+
source,
2213+
profile_id,
2214+
"credentials.refresh",
2215+
"github_app_installation requires a token_url ending in /app/installations/{installation_id}/access_tokens and uses permissions material instead of scopes",
2216+
));
2217+
}
22012218
if refresh.strategy == ProviderCredentialRefreshStrategy::Unspecified {
22022219
diagnostics.push(ProfileValidationDiagnostic::error(
22032220
source,
@@ -5896,6 +5913,25 @@ binaries:
58965913
);
58975914
}
58985915

5916+
#[test]
5917+
fn github_app_profile_roundtrip_and_runtime_credentials() {
5918+
let profile = example_profile("github-app");
5919+
assert!(profile.required_static_credentials().is_empty());
5920+
assert!(validate_profile_set(&[("github-app.yaml".into(), profile.clone())]).is_empty());
5921+
let proto = profile.to_proto();
5922+
assert_eq!(
5923+
proto.credentials[0].refresh.as_ref().unwrap().strategy,
5924+
openshell_core::proto::ProviderCredentialRefreshStrategy::GithubAppInstallation as i32
5925+
);
5926+
let restored = ProviderTypeProfile::from_proto(&proto);
5927+
let yaml = serde_yml::to_string(&restored).unwrap();
5928+
assert!(yaml.contains("github_app_installation"));
5929+
let mut invalid = profile.clone();
5930+
invalid.credentials[0].refresh.as_mut().unwrap().token_url =
5931+
"https://api.github.com/token".into();
5932+
assert!(!validate_profile_set(&[("invalid.yaml".into(), invalid)]).is_empty());
5933+
}
5934+
58995935
#[test]
59005936
fn aws_sts_strategy_serde_roundtrip() {
59015937
use openshell_core::proto::ProviderCredentialRefreshStrategy;

0 commit comments

Comments
 (0)