From cb9c90cf9e3f0586061f4aee3a646d654e41a27e Mon Sep 17 00:00:00 2001 From: Gordon Sim Date: Thu, 24 Sep 2026 21:04:03 +0100 Subject: [PATCH] feat(provider): add a refresh strategy for github app installations Signed-off-by: Gordon Sim --- CI.md | 18 + architecture/build.md | 10 + architecture/gateway.md | 29 + crates/openshell-cli/src/commands/common.rs | 208 +++++- crates/openshell-cli/src/commands/provider.rs | 6 +- crates/openshell-cli/src/main.rs | 35 + .../sandbox_create_lifecycle_integration.rs | 10 +- crates/openshell-providers/src/profiles.rs | 36 + crates/openshell-server/src/grpc/provider.rs | 676 +++++++++++++++++- .../openshell-server/src/provider_refresh.rs | 12 +- .../src/provider_refresh/github_app.rs | 666 +++++++++++++++++ crates/openshell-server/src/storage_proto.rs | 12 +- crates/openshell-tui/src/app.rs | 3 + docs/providers/profiles.mdx | 106 ++- docs/sandboxes/manage-sandboxes.mdx | 2 + docs/sdk/go.mdx | 5 + e2e/rust/Cargo.toml | 5 + e2e/rust/e2e-podman.sh | 1 + e2e/rust/src/harness/container.rs | 24 +- e2e/rust/tests/provider_github_app.rs | 29 + e2e/rust/tests/provider_refresh_handles.rs | 361 +--------- e2e/rust/tests/support/github_app_fixture.py | 231 ++++++ e2e/rust/tests/support/github_app_tls.rs | 86 +++ .../tests/support/oauth_refresh_fixture.py | 55 ++ .../tests/support/provider_refresh_handles.rs | 489 +++++++++++++ proto/openshell.proto | 2 + providers/README.md | 5 + providers/github-app.yaml | 66 ++ sdk/go/openshell/v1/example_test.go | 10 + .../v1/internal/converter/refresh.go | 4 + .../v1/internal/converter/refresh_test.go | 2 + sdk/go/openshell/v1/refresh.go | 1 + sdk/go/openshell/v1/types/refresh.go | 1 + sdk/go/proto/openshellv1/openshell.pb.go | 9 +- skills/debug-openshell-cluster/SKILL.md | 4 + skills/openshell-cli/SKILL.md | 18 + 36 files changed, 2829 insertions(+), 408 deletions(-) create mode 100644 crates/openshell-server/src/provider_refresh/github_app.rs create mode 100644 e2e/rust/tests/provider_github_app.rs create mode 100644 e2e/rust/tests/support/github_app_fixture.py create mode 100644 e2e/rust/tests/support/github_app_tls.rs create mode 100644 e2e/rust/tests/support/oauth_refresh_fixture.py create mode 100644 e2e/rust/tests/support/provider_refresh_handles.rs create mode 100644 providers/github-app.yaml diff --git a/CI.md b/CI.md index 7555935a9c..e8396883e6 100644 --- a/CI.md +++ b/CI.md @@ -34,6 +34,24 @@ The GitHub ruleset should require the `OpenShell / ...` statuses published by `Required CI Gates` plus the direct `OpenShell / Trivy Changes` result, not the push-triggered workflow jobs themselves. +The GitHub App sandbox regression runs in the curated `e2e:podman:ci` suite +used by the branch Podman E2E job. It is also independently selectable: + +```shell +OPENSHELL_E2E_PODMAN_TEST=provider_github_app mise run e2e:podman +``` + +It builds a tool image with `gh` and Git, then uses a local installation-token +issuer and authenticated HTTPS API/Git fixture. It checks both clients across +12 rotations, revocation after reconfiguration, and a fresh process using the +replacement handle. No live GitHub app or installation is required. It needs +the wrapper-managed gateway so it can temporarily trust the fixture CA; the +original gateway configuration is restored afterward. The OAuth counterpart +remains separately selectable as `provider_refresh_handles`. It uses an HTTP +fixture and the standard workload image, and supports both wrapper-managed +gateways and existing gateways selected with `OPENSHELL_GATEWAY_ENDPOINT`. +It does not install CA trust or restart the gateway. + ## Informational security reports Security workflow compute runs directly on GitHub-hosted runners instead of diff --git a/architecture/build.md b/architecture/build.md index a447b03d60..220d2ecc9c 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -108,6 +108,16 @@ test identity and tools, with Python aligned to the host test runner for serialized callable compatibility. Default-image coverage retains the product image. Other compute-driver test lanes retain their existing workload fixtures. +The Podman E2E gateway pins both the supervisor and sandbox runtime images +selected by its wrapper, so a local test cannot mix a checkout's supervisor +with a registry-default sandbox runtime. GitHub App credential tests run as a +separate target with a local HTTPS API and Git backend, ephemeral CA trust, and +real workload clients; they require no live GitHub credentials. The target is +included in the curated Podman suite used by branch E2E CI. +Only the GitHub App variant requires a wrapper-managed gateway to install CA +trust. OAuth refresh-handle coverage uses HTTP and the standard workload image, +so it also runs against an existing gateway without changing its configuration. + The Docker image pipeline is a two-step flow: build the Rust binary natively for the target architecture, then assemble the container image from the prebuilt binary. The gateway, sandbox, and supervisor images use distinct diff --git a/architecture/gateway.md b/architecture/gateway.md index 2f626286e6..90f4538c14 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -274,6 +274,13 @@ their profile payloads. The CLI exposes reusable profile definitions through `openshell profile`, with `list` and `describe` reading the same effective catalog used by provider creation. Export, import, update, lint, and delete share that top-level command group. Workspace selection and explicit platform scope apply at the existing profile API boundary; `openshell provider` manages credential-bearing instances. +CLI warnings for credentials passed through `--env` derive setup suggestions from +that catalog. Static credentials retain existing-token setup; gateway-mintable +credentials recommend runtime creation and explicit refresh configuration when +the profile supports runtime creation. Other runtime setups use generic provider +guidance. Shared environment aliases do not identify the authentication method: +GitHub tokens and GitHub App installations remain separate choices. + Each logical gateway request captures the selected sources into one validated, immutable effective catalog before deriving provider behavior. Policy layers, credential scope, injected environment material, dynamic token grants, and @@ -788,6 +795,28 @@ credential storage for defense in depth. Multi-replica deployments can use that default with a shared database and shared key-encryption key, or opt into an external backend such as Vault or Kubernetes Secrets. +GitHub App installation refresh signs an app JWT at the gateway and exchanges it +for an installation token using a profile-owned endpoint and its default REST API +version, allowing GitHub Enterprise Server endpoints to use their supported version. +Each provider pins one installation and explicit repository IDs and permissions +in its refresh material; there is no workload-selected scope or installation-wide +default. The token's logical profile name resolves to the configured environment +alias, or the first declared alias before configuration. Refresh configuration +reserves one alias per logical GitHub App credential under the provider mutation +lock, including pending and failed grants. Management operations use the same +resolution; exact-key deletion remains available to clean up legacy conflicts. +The app key is secret material, and only the installation token enters the +existing credential distribution path. Refresh honors the issuer expiry, caps +local use at one hour, and preserves the existing authorization epoch during +routine rotation. Explicit +reconfiguration revokes the old workload handles. Network policy remains an +independent constraint on token use. GitHub mint failures use gateway-owned +recovery classifications without storing issuer-controlled error prose. +Transport diagnostics classify typed timeout, TLS, and connection failures +without exposing request material or raw error chains. +The Go SDK exposes this strategy as `v1.RefreshStrategyGitHubAppInstallation` +through its curated `openshell/v1` package for use in `v1.RefreshConfig`. + The Vault credential driver requires HTTPS for every non-loopback backend, never follows HTTP redirects, and keeps standard certificate hostname verification enabled. Operators can add private Vault trust roots with a PEM diff --git a/crates/openshell-cli/src/commands/common.rs b/crates/openshell-cli/src/commands/common.rs index 8eb7de2756..3997ba7433 100644 --- a/crates/openshell-cli/src/commands/common.rs +++ b/crates/openshell-cli/src/commands/common.rs @@ -22,7 +22,7 @@ use std::io::IsTerminal; use std::process::Command; use std::time::{Duration, Instant}; -const DOCS_PROVIDERS_URL: &str = "https://docs.nvidia.com/openshell/latest/sandboxes/providers-v2"; +const DOCS_PROVIDERS_URL: &str = "https://docs.nvidia.com/openshell/latest/providers/profiles"; // --------------------------------------------------------------------------- // View types @@ -755,6 +755,9 @@ pub fn parse_duration_to_ms(s: &str) -> Result { pub struct ProfileSuggestion { pub provider_type: String, pub credential: String, + credential_key: String, + display_name: String, + refresh: Option, } fn credential_env_matches( @@ -785,10 +788,23 @@ fn credential_env_matches( let mut suggestions = Vec::new(); for profile in profiles { for cred in &profile.credentials { - if cred.env_vars.iter().any(|v| v.eq_ignore_ascii_case(key)) { + if let Some(credential_key) = + cred.env_vars.iter().find(|v| v.eq_ignore_ascii_case(key)) + { + let refresh = cred.refresh.as_ref().filter(|r| r.is_gateway_mintable()); + // Do not offer a static shortcut for runtime credentials. + // Mixed profiles and token grants need their full setup guide. + if cred.is_runtime_resolvable() + && (refresh.is_none() || !profile.allows_runtime_provider_credentials()) + { + continue; + } suggestions.push(ProfileSuggestion { provider_type: profile.id.clone(), credential: cred.name.clone(), + credential_key: credential_key.clone(), + display_name: profile.display_name.clone(), + refresh: refresh.cloned(), }); } } @@ -800,7 +816,13 @@ fn credential_env_matches( for key in env.keys() { let sug = profile_suggestions(key); - if !sug.is_empty() || looks_like_credential(key) { + let known_credential = profiles.iter().any(|profile| { + profile + .credentials + .iter() + .any(|cred| cred.env_vars.iter().any(|v| v.eq_ignore_ascii_case(key))) + }); + if known_credential || looks_like_credential(key) { matches.push((key.clone(), sug)); } } @@ -824,34 +846,78 @@ pub fn warn_credential_env_vars( return; } - let matches = credential_env_matches(env, profiles); - if matches.is_empty() { - return; + for warning in credential_env_warnings(env, profiles) { + eprintln!("{warning}"); } +} - for (key, suggestions) in &matches { - eprintln!( - "{} {key} looks like a credential passed as a plain environment variable.", - "⚠".yellow() - ); - eprintln!(" The agent inside the sandbox can read this value directly."); - eprintln!(); +fn credential_env_warnings( + env: &HashMap, + profiles: &[openshell_providers::ProviderTypeProfile], +) -> Vec { + let mut warnings = Vec::new(); + for (key, suggestions) in credential_env_matches(env, profiles) { + let mut lines = vec![ + format!( + "{} {key} looks like a credential passed as a plain environment variable.", + "⚠".yellow() + ), + " The agent inside the sandbox can read this value directly.".to_owned(), + String::new(), + ]; if suggestions.is_empty() { - eprintln!(" To hide it from the agent, use a provider instead of --env."); + lines.push(" To hide it from the agent, use a provider instead of --env.".to_owned()); } else { - eprintln!(" To hide it from the agent, use a provider instead:"); - for s in suggestions { - eprintln!( - " openshell provider create --name my-{ty} --type {ty} --credential {key}", - ty = s.provider_type - ); + lines.push(" To hide it from the agent, choose a provider setup that matches your authentication:".to_owned()); + for s in &suggestions { + if let Some(refresh) = &s.refresh { + let strategy = + super::provider::provider_refresh_strategy_name(refresh.strategy) + .replace('_', "-"); + lines.push(format!( + " {} ({strategy}, gateway-managed refresh):", + s.display_name + )); + lines.push(format!( + " openshell provider create --name my-{ty} --type {ty} --runtime-credentials", + ty = s.provider_type + )); + lines.push(format!( + " Then run openshell provider refresh configure my-{} --credential-key {} --strategy {strategy}", + s.provider_type, s.credential_key + )); + let material = refresh + .material + .iter() + .filter(|m| m.required) + .map(|m| m.name.as_str()) + .collect::>(); + if !material.is_empty() { + lines.push(format!( + " Supply required refresh material: {}. Use --secret-material-env for secrets.", + material.join(", ") + )); + } + lines.push(" Follow the refresh setup guide before attaching this provider to a sandbox.".to_owned()); + } else { + lines.push(format!( + " {} (existing token or credential):", + s.display_name + )); + lines.push(format!( + " openshell provider create --name my-{ty} --type {ty} --credential {key}", + ty = s.provider_type + )); + } } - eprintln!(" openshell sandbox create --provider my- ..."); + lines.push(" openshell sandbox create --provider my- ...".to_owned()); } - eprintln!(" See: {DOCS_PROVIDERS_URL}"); - eprintln!(); + lines.push(format!(" See: {DOCS_PROVIDERS_URL}")); + lines.push(String::new()); + warnings.push(lines.join("\n")); } + warnings } pub fn parse_key_value_pairs(items: &[String], flag: &str) -> Result> { @@ -1180,13 +1246,15 @@ mod tests { assert_eq!(prof[0].0, "GITHUB_TOKEN"); let sug = &prof[0].1; - assert_eq!(sug.len(), 2_usize); + assert_eq!(sug.len(), 3_usize); assert_eq!(sug[0].provider_type, "copilot"); assert_eq!(sug[0].credential, "api_token"); assert_eq!(sug[1].provider_type, "github"); assert_eq!(sug[1].credential, "api_token"); + assert_eq!(sug[2].provider_type, "github-app"); + assert_eq!(sug[2].credential, "api_token"); } #[test] @@ -1198,13 +1266,103 @@ mod tests { assert_eq!(prof[0].0, "gh_token"); let sug = &prof[0].1; - assert_eq!(sug.len(), 2_usize); + assert_eq!(sug.len(), 3_usize); assert_eq!(sug[0].provider_type, "copilot"); assert_eq!(sug[0].credential, "api_token"); assert_eq!(sug[1].provider_type, "github"); assert_eq!(sug[1].credential, "api_token"); + assert_eq!(sug[2].provider_type, "github-app"); + assert_eq!(sug[2].credential, "api_token"); + } + + #[test] + fn credential_warning_distinguishes_existing_github_tokens_from_app_refresh() { + for key in ["GITHUB_TOKEN", "GH_TOKEN", "gh_token"] { + let warnings = credential_env_warnings(&env(&[(key, "secretVALUE42")]), catalog()); + let warning = &warnings[0]; + assert!(warning.contains(&format!( + "openshell provider create --name my-github --type github --credential {key}" + ))); + assert!(warning.contains("GitHub (existing token or credential)")); + assert!( + warning.contains("GitHub App (github-app-installation, gateway-managed refresh)") + ); + assert!(warning.contains( + "openshell provider create --name my-github-app --type github-app --runtime-credentials" + )); + assert!(warning.contains(&format!( + "openshell provider refresh configure my-github-app --credential-key {} --strategy github-app-installation", + key.to_ascii_uppercase() + ))); + assert!( + warning.contains( + "client_id, installation_id, private_key, repository_ids, permissions" + ) + ); + assert!(warning.contains("--secret-material-env")); + assert!(warning.contains(DOCS_PROVIDERS_URL)); + assert!(!warning.contains("--type github-app --credential")); + assert!(!warning.contains("secretVALUE42")); + } + } + + #[test] + fn credential_warning_uses_profile_capabilities_not_its_name() { + let mut profile = catalog() + .iter() + .find(|p| p.id == "github-app") + .unwrap() + .clone(); + profile.id = "enterprise-repos".to_owned(); + let env = env(&[("GITHUB_TOKEN", "secretVALUE42")]); + let warning = credential_env_warnings(&env, &[profile.clone()]).join("\n"); + assert!(warning.contains("--type enterprise-repos --runtime-credentials")); + assert!(warning.contains("--strategy github-app-installation")); + + // A refresh declaration alone does not imply gateway minting. + for strategy in [ + openshell_core::proto::ProviderCredentialRefreshStrategy::Static, + openshell_core::proto::ProviderCredentialRefreshStrategy::External, + ] { + profile.credentials[0].refresh.as_mut().unwrap().strategy = strategy; + let warning = credential_env_warnings(&env, &[profile.clone()]).join("\n"); + assert!(warning.contains("--type enterprise-repos --credential GITHUB_TOKEN")); + assert!(!warning.contains("--runtime-credentials")); + assert!(!warning.contains("refresh configure")); + } + } + + #[test] + fn credential_warning_does_not_offer_invalid_runtime_creation_for_mixed_profile() { + let mut profile = catalog() + .iter() + .find(|p| p.id == "github-app") + .unwrap() + .clone(); + let mut static_credential = profile.credentials[0].clone(); + static_credential.name = "other_secret".to_owned(); + static_credential.env_vars = vec!["OTHER_SECRET".to_owned()]; + static_credential.refresh = None; + profile.credentials.push(static_credential); + // A known alias must still warn even without a credential keyword. + profile.credentials[0].env_vars = vec!["CUSTOM_AUTH".to_owned()]; + let warning = credential_env_warnings(&env(&[("CUSTOM_AUTH", "x")]), &[profile]).join("\n"); + assert!(warning.contains("CUSTOM_AUTH looks like a credential")); + assert!(warning.contains("use a provider instead of --env")); + assert!(!warning.contains("provider create")); + } + + #[test] + fn credential_warning_without_catalog_is_generic_and_does_not_leak_values() { + let warnings = credential_env_warnings(&env(&[("APP_SECRET", "secretVALUE42")]), &[]); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("APP_SECRET looks like a credential")); + assert!(warnings[0].contains("use a provider instead of --env")); + assert!(!warnings[0].contains("provider create")); + assert!(!warnings[0].contains("secretVALUE42")); + assert!(credential_env_warnings(&env(&[("PATH", "/usr/bin")]), &[]).is_empty()); } #[test] diff --git a/crates/openshell-cli/src/commands/provider.rs b/crates/openshell-cli/src/commands/provider.rs index 3e889e025c..ab0ca0d439 100644 --- a/crates/openshell-cli/src/commands/provider.rs +++ b/crates/openshell-cli/src/commands/provider.rs @@ -2063,6 +2063,7 @@ fn provider_refresh_strategy(strategy: &str) -> Result Ok(ProviderCredentialRefreshStrategy::AwsStsAssumeRole), + "github_app_installation" => Ok(ProviderCredentialRefreshStrategy::GithubAppInstallation), _ => Err(miette!("unsupported provider refresh strategy: {strategy}")), } } @@ -2106,7 +2107,9 @@ fn provider_refresh_recovery_action_name( } } -fn provider_refresh_strategy_name(strategy: ProviderCredentialRefreshStrategy) -> &'static str { +pub(super) fn provider_refresh_strategy_name( + strategy: ProviderCredentialRefreshStrategy, +) -> &'static str { match strategy { ProviderCredentialRefreshStrategy::Static => "static", ProviderCredentialRefreshStrategy::External => "external", @@ -2114,6 +2117,7 @@ fn provider_refresh_strategy_name(strategy: ProviderCredentialRefreshStrategy) - ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => "oauth2_client_credentials", ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => "google_service_account_jwt", ProviderCredentialRefreshStrategy::AwsStsAssumeRole => "aws_sts_assume_role", + ProviderCredentialRefreshStrategy::GithubAppInstallation => "github_app_installation", ProviderCredentialRefreshStrategy::Unspecified => "unspecified", } } diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index eb52e3f253..dbcedd1c6b 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -776,6 +776,7 @@ enum CliProviderRefreshStrategy { Oauth2ClientCredentials, GoogleServiceAccountJwt, AwsStsAssumeRole, + GithubAppInstallation, } impl CliProviderRefreshStrategy { @@ -785,6 +786,7 @@ impl CliProviderRefreshStrategy { Self::Oauth2ClientCredentials => "oauth2_client_credentials", Self::GoogleServiceAccountJwt => "google_service_account_jwt", Self::AwsStsAssumeRole => "aws_sts_assume_role", + Self::GithubAppInstallation => "github_app_installation", } } } @@ -5625,6 +5627,39 @@ mod tests { assert!(msg.contains("--from-gcloud-adc")); } + #[test] + fn github_app_refresh_command_parses() { + let config = Cli::try_parse_from([ + "openshell", + "provider", + "refresh", + "configure", + "repo-reader", + "--credential-key", + "GITHUB_TOKEN", + "--strategy", + "github-app-installation", + "--material", + "repository_ids=[42]", + "--material", + "permissions={\"contents\":\"read\"}", + "--secret-material-env", + "private_key=GITHUB_APP_PRIVATE_KEY", + ]) + .expect("GitHub App refresh configuration should parse"); + assert!(matches!( + config.command, + Some(Commands::Provider { + command: Some(ProviderCommands::Refresh( + ProviderRefreshCommands::Configure { + strategy: CliProviderRefreshStrategy::GithubAppInstallation, + .. + } + )) + }) + )); + } + #[test] fn provider_refresh_commands_parse() { let status = Cli::try_parse_from([ diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 0b638d5694..5814087363 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -3328,8 +3328,14 @@ async fn sandbox_template_create_warns_for_credential_env_vars() { "template create should warn for credential-looking --env values: {stderr}" ); assert!( - stderr.contains("To hide it from the agent, use a provider instead"), - "warning should point users toward providers: {stderr}" + stderr.contains( + "openshell provider create --name my-openai --type openai --credential OPENAI_API_KEY" + ), + "warning should recommend existing-credential provider setup: {stderr}" + ); + assert!( + !stderr.contains("plain-secret"), + "warning must not expose the credential value" ); let requests = template_create_requests(&server).await; diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 01991d712e..2f19dc54e9 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -1063,6 +1063,7 @@ pub fn is_gateway_mintable_strategy(strategy: ProviderCredentialRefreshStrategy) | ProviderCredentialRefreshStrategy::Oauth2ClientCredentials | ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt | ProviderCredentialRefreshStrategy::AwsStsAssumeRole + | ProviderCredentialRefreshStrategy::GithubAppInstallation ) } @@ -1313,6 +1314,7 @@ pub fn provider_refresh_strategy_from_yaml(raw: &str) -> Option Some(ProviderCredentialRefreshStrategy::AwsStsAssumeRole), + "github_app_installation" => Some(ProviderCredentialRefreshStrategy::GithubAppInstallation), _ => None, } } @@ -1328,6 +1330,7 @@ pub fn provider_refresh_strategy_to_yaml( ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => "oauth2_client_credentials", ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => "google_service_account_jwt", ProviderCredentialRefreshStrategy::AwsStsAssumeRole => "aws_sts_assume_role", + ProviderCredentialRefreshStrategy::GithubAppInstallation => "github_app_installation", ProviderCredentialRefreshStrategy::Unspecified => "unspecified", } } @@ -2198,6 +2201,20 @@ pub fn validate_profile_set( } if let Some(refresh) = credential.refresh.as_ref() { + if refresh.strategy == ProviderCredentialRefreshStrategy::GithubAppInstallation + && (!refresh + .token_url + .ends_with("/app/installations/{installation_id}/access_tokens") + || refresh.token_url.matches("{installation_id}").count() != 1 + || !refresh.scopes.is_empty()) + { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.refresh", + "github_app_installation requires a token_url ending in /app/installations/{installation_id}/access_tokens and uses permissions material instead of scopes", + )); + } if refresh.strategy == ProviderCredentialRefreshStrategy::Unspecified { diagnostics.push(ProfileValidationDiagnostic::error( source, @@ -5896,6 +5913,25 @@ binaries: ); } + #[test] + fn github_app_profile_roundtrip_and_runtime_credentials() { + let profile = example_profile("github-app"); + assert!(profile.required_static_credentials().is_empty()); + assert!(validate_profile_set(&[("github-app.yaml".into(), profile.clone())]).is_empty()); + let proto = profile.to_proto(); + assert_eq!( + proto.credentials[0].refresh.as_ref().unwrap().strategy, + openshell_core::proto::ProviderCredentialRefreshStrategy::GithubAppInstallation as i32 + ); + let restored = ProviderTypeProfile::from_proto(&proto); + let yaml = serde_yml::to_string(&restored).unwrap(); + assert!(yaml.contains("github_app_installation")); + let mut invalid = profile.clone(); + invalid.credentials[0].refresh.as_mut().unwrap().token_url = + "https://api.github.com/token".into(); + assert!(!validate_profile_set(&[("invalid.yaml".into(), invalid)]).is_empty()); + } + #[test] fn aws_sts_strategy_serde_roundtrip() { use openshell_core::proto::ProviderCredentialRefreshStrategy; diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 7cbc2749fa..265f30ed57 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -3197,6 +3197,89 @@ fn provider_refresh_defaults( .and_then(|credential| credential.refresh.clone()) } +#[derive(Clone, Copy)] +enum RefreshCredentialLookup { + Resolve, + Delete, +} + +/// A GitHub App credential has one refresh grant, stored under the selected +/// environment alias. Logical names are selectors, never injectable keys. +/// Configuration callers must hold the sandbox mutation guard through persist. +async fn resolve_github_app_refresh_key( + store: &Store, + catalog: &EffectiveProviderProfileCatalog, + provider: &Provider, + requested_key: &str, + lookup: RefreshCredentialLookup, +) -> Result { + let Some(profile) = + get_provider_type_profile_for_scope(catalog, &provider.r#type, &provider.profile_workspace) + else { + return Ok(requested_key.to_string()); + }; + let Some(credential) = profile.credentials.iter().find(|credential| { + (credential.name == requested_key + || credential.env_vars.iter().any(|key| key == requested_key)) + && credential.refresh.as_ref().is_some_and(|refresh| { + refresh.strategy == ProviderCredentialRefreshStrategy::GithubAppInstallation + }) + }) else { + return Ok(requested_key.to_string()); + }; + let is_logical_name = credential.name == requested_key + && !credential + .env_vars + .iter() + .any(|alias| alias == requested_key); + let states = + crate::provider_refresh::list_refresh_states_for_provider(store, provider.object_id()) + .await?; + let mut configured_keys: Vec<_> = states + .iter() + .filter(|state| { + state.credential_key == credential.name + || credential.env_vars.contains(&state.credential_key) + }) + .map(|state| state.credential_key.as_str()) + .collect(); + configured_keys.sort_unstable(); + // Exact deletion must remain possible even for legacy logical-name states + // or multiple aliases. Do not silently migrate or merge their grants. + if matches!(lookup, RefreshCredentialLookup::Delete) && configured_keys.contains(&requested_key) + { + return Ok(requested_key.to_string()); + } + if configured_keys.len() > 1 + || configured_keys + .iter() + .any(|key| !credential.env_vars.iter().any(|alias| alias == key)) + { + return Err(Status::failed_precondition(format!( + "GitHub App credential '{}' has conflicting or legacy refresh keys: {}; delete the conflicting refresh configurations by exact key before reconfiguring", + credential.name, + configured_keys.join(", ") + ))); + } + if let Some(existing_key) = configured_keys.first() { + if is_logical_name || requested_key == *existing_key { + return Ok((*existing_key).to_string()); + } + return Err(Status::failed_precondition(format!( + "GitHub App credential '{}' already has refresh configured on {existing_key}; update that key or delete its refresh configuration before switching aliases", + credential.name + ))); + } + if is_logical_name { + return credential.env_vars.first().cloned().ok_or_else(|| { + Status::failed_precondition( + "github_app_installation requires a credential with an environment alias", + ) + }); + } + Ok(requested_key.to_string()) +} + /// Resolve the env keys a refresh co-mints (beyond its primary credential) from /// the provider's profile `additional_outputs`. Returns semantic output id -> /// env key. Empty when the provider type has no profile or the credential @@ -4443,11 +4526,23 @@ pub(super) async fn handle_get_provider_refresh_status( ) .await? } else { + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), &workspace) + .await?; + let credential_key = resolve_github_app_refresh_key( + state.store.as_ref(), + &catalog, + &provider, + request.credential_key.trim(), + RefreshCredentialLookup::Resolve, + ) + .await?; crate::provider_refresh::get_refresh_state( state.store.as_ref(), &workspace, provider.object_id(), - request.credential_key.trim(), + &credential_key, ) .await? .into_iter() @@ -4488,11 +4583,6 @@ pub(super) async fn handle_configure_provider_refresh( if credential_key.is_empty() { return Err(Status::invalid_argument("credential_key is required")); } - if !is_valid_env_key(credential_key) { - return Err(Status::invalid_argument( - "credential_key must be a valid environment variable name", - )); - } let strategy = ProviderCredentialRefreshStrategy::try_from(request.strategy) .unwrap_or(ProviderCredentialRefreshStrategy::Unspecified); if strategy == ProviderCredentialRefreshStrategy::Unspecified { @@ -4615,6 +4705,20 @@ pub(super) async fn handle_configure_provider_refresh( .provider_profile_sources .snapshot_catalog(state.store.as_ref(), &workspace) .await?; + let resolved_key = resolve_github_app_refresh_key( + state.store.as_ref(), + &catalog, + &provider, + credential_key, + RefreshCredentialLookup::Resolve, + ) + .await?; + let credential_key = resolved_key.as_str(); + if !is_valid_env_key(credential_key) { + return Err(Status::invalid_argument( + "credential_key must resolve to a valid environment variable name", + )); + } validate_provider_credential_key_available_for_attached_sandboxes_with_catalog( state.store.as_ref(), &catalog, @@ -4647,6 +4751,16 @@ pub(super) async fn handle_configure_provider_refresh( &additional_output_keys, )?; validate_refresh_material(&request.material, refresh_defaults.as_ref())?; + if strategy == ProviderCredentialRefreshStrategy::GithubAppInstallation { + let defaults = refresh_defaults.as_ref().filter(|defaults| defaults.strategy == strategy) + .ok_or_else(|| Status::failed_precondition( + "github_app_installation requires a matching provider profile refresh declaration", + ))?; + crate::provider_refresh::validate_github_app_configuration( + &request.material, + &defaults.token_url, + )?; + } let mut secret_material_keys: HashSet = request.secret_material_keys.iter().cloned().collect(); for key in &request.secret_material_keys { @@ -4903,13 +5017,31 @@ pub(super) async fn handle_rotate_provider_credential( if credential_key.is_empty() { return Err(Status::invalid_argument("credential_key is required")); } + let provider = state + .store + .get_message_by_name::(&workspace, provider_name) + .await + .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? + .ok_or_else(|| Status::not_found("provider not found"))?; + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), &workspace) + .await?; + let credential_key = resolve_github_app_refresh_key( + state.store.as_ref(), + &catalog, + &provider, + credential_key, + RefreshCredentialLookup::Resolve, + ) + .await?; let refresh_state = crate::provider_refresh::refresh_provider_credential( state.store.as_ref(), &workspace, &state.credentials, Some(&state.compute), provider_name, - credential_key, + &credential_key, ) .await?; @@ -4978,11 +5110,23 @@ pub(super) async fn handle_delete_provider_refresh( .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| Status::not_found("provider not found"))?; + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), &workspace) + .await?; + let credential_key = resolve_github_app_refresh_key( + state.store.as_ref(), + &catalog, + &provider, + credential_key, + RefreshCredentialLookup::Delete, + ) + .await?; let existing_refresh_state = crate::provider_refresh::get_refresh_state( state.store.as_ref(), &workspace, provider.object_id(), - credential_key, + &credential_key, ) .await?; let Some(refresh_state) = existing_refresh_state else { @@ -5009,7 +5153,7 @@ pub(super) async fn handle_delete_provider_refresh( // update land between the read and the write and then be clobbered (CWE-362). if crate::provider_refresh::refresh_has_expiration(&refresh_state) { let refresh_expires_at_ms = refresh_state.expires_at_ms; - let owned_keys: Vec = std::iter::once(credential_key.to_string()) + let owned_keys: Vec = std::iter::once(credential_key) .chain(refresh_state.additional_output_keys.into_values()) .collect(); state @@ -6298,6 +6442,7 @@ mod tests { "cursor", "deepinfra", "github", + "github-app", "google-cloud", "google-vertex-ai", "nvidia", @@ -7291,6 +7436,519 @@ mod tests { assert!(err.message().contains("default/global-provider")); } + async fn github_app_refresh_fixture() -> ( + Arc, + wiremock::MockServer, + Provider, + ConfigureProviderRefreshRequest, + ) { + let state = test_server_state().await; + let server = wiremock::MockServer::start().await; + let mut profile = openshell_providers::example_profiles::load("github-app").to_proto(); + profile.id = "test-github-app".into(); + profile.credentials[0].refresh.as_mut().unwrap().token_url = format!( + "{}/app/installations/{{installation_id}}/access_tokens", + server.uri() + ); + let imported = handle_import_provider_profiles( + &state, + authed_request(ImportProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(profile), + source: "test.yaml".into(), + }], + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + ..Default::default() + }), + ) + .await + .unwrap() + .into_inner(); + assert!(imported.imported, "{:?}", imported.diagnostics); + let provider = create_provider_record( + state.store.as_ref(), + "default", + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + name: "github-app-test".into(), + workspace: "default".into(), + ..Default::default() + }), + r#type: "test-github-app".into(), + profile_workspace: "default".into(), + ..Default::default() + }, + ) + .await + .unwrap(); + let configure = ConfigureProviderRefreshRequest { + provider: "github-app-test".into(), + credential_key: "GITHUB_TOKEN".into(), + strategy: ProviderCredentialRefreshStrategy::GithubAppInstallation as i32, + material: HashMap::from([ + ("client_id".into(), "Iv1.test".into()), + ("installation_id".into(), "123".into()), + ( + "private_key".into(), + crate::provider_refresh::TEST_RSA_PRIVATE_KEY.into(), + ), + ("repository_ids".into(), "[42]".into()), + ("permissions".into(), r#"{"contents":"read"}"#.into()), + ]), + // The strategy itself must classify the signing key as secret. + secret_material_keys: Vec::new(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + ..Default::default() + }; + (state, server, provider, configure) + } + + #[tokio::test] + async fn github_app_refresh_aliases_resolve_through_management_and_injection() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, ResponseTemplate}; + + for (requested_key, expected_key) in [ + ("api_token", "GITHUB_TOKEN"), + ("GITHUB_TOKEN", "GITHUB_TOKEN"), + ("GH_TOKEN", "GH_TOKEN"), + ] { + let (state, server, provider, mut configure) = github_app_refresh_fixture().await; + configure.credential_key = requested_key.into(); + let configured = + handle_configure_provider_refresh(&state, authed_request(configure.clone())) + .await + .unwrap() + .into_inner(); + assert_eq!(configured.status.unwrap().credential_key, expected_key); + // A logical-name update retains the explicitly selected alias. + configure.credential_key = "api_token".into(); + let updated = handle_configure_provider_refresh(&state, authed_request(configure)) + .await + .unwrap() + .into_inner(); + assert_eq!(updated.status.unwrap().credential_key, expected_key); + Mock::given(method("POST")) + .and(path("/app/installations/123/access_tokens")) + .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({ + "token": "installation-token", + "expires_at": (chrono::Utc::now() + chrono::Duration::minutes(30)).to_rfc3339() + }))) + .expect(1) + .mount(&server) + .await; + let rotated = handle_rotate_provider_credential( + &state, + authed_request(RotateProviderCredentialRequest { + provider: provider.object_name().into(), + credential_key: "api_token".into(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + ..Default::default() + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(rotated.status.unwrap().credential_key, expected_key); + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), "default") + .await + .unwrap(); + let environment = resolve_provider_environment_with_credentials( + state.store.as_ref(), + &catalog, + "default", + &[provider.object_name().into()], + &state.credentials, + ) + .await + .unwrap(); + assert_eq!( + environment.get(expected_key).map(String::as_str), + Some("installation-token") + ); + assert!(!environment.contains_key("api_token")); + let binding = &environment.static_credential_bindings[expected_key]; + assert!(!binding.endpoints.is_empty()); + for key in ["GITHUB_TOKEN", "GH_TOKEN"] { + assert_eq!(environment.contains_key(key), key == expected_key); + } + let status = handle_get_provider_refresh_status( + &state, + authed_request(GetProviderRefreshStatusRequest { + provider: provider.object_name().into(), + credential_key: "api_token".into(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(status.credentials.len(), 1); + assert_eq!(status.credentials[0].credential_key, expected_key); + handle_delete_provider_refresh( + &state, + authed_request(DeleteProviderRefreshRequest { + provider: provider.object_name().into(), + credential_key: "api_token".into(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + ..Default::default() + }), + ) + .await + .unwrap(); + assert!( + crate::provider_refresh::list_refresh_states_for_provider( + state.store.as_ref(), + provider.object_id(), + ) + .await + .unwrap() + .is_empty() + ); + } + } + + #[tokio::test] + async fn github_app_refresh_rejects_sibling_alias_including_failed_grants() { + for (selected, sibling) in [("GITHUB_TOKEN", "GH_TOKEN"), ("GH_TOKEN", "GITHUB_TOKEN")] { + let (state, _server, provider, mut request) = github_app_refresh_fixture().await; + request.credential_key = selected.into(); + handle_configure_provider_refresh(&state, authed_request(request.clone())) + .await + .unwrap(); + for status in ["pending", "error"] { + let mut original = crate::provider_refresh::get_refresh_state( + state.store.as_ref(), + "default", + provider.object_id(), + selected, + ) + .await + .unwrap() + .unwrap(); + original.status = status.into(); + crate::provider_refresh::put_refresh_state(state.store.as_ref(), &original) + .await + .unwrap(); + let before = crate::provider_refresh::get_refresh_state( + state.store.as_ref(), + "default", + provider.object_id(), + selected, + ) + .await + .unwrap() + .unwrap(); + let mut conflicting = request.clone(); + conflicting.credential_key = sibling.into(); + conflicting + .material + .insert("installation_id".into(), "456".into()); + conflicting + .material + .insert("repository_ids".into(), "[43]".into()); + let err = handle_configure_provider_refresh(&state, authed_request(conflicting)) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + assert!(err.message().contains(selected)); + let after = crate::provider_refresh::list_refresh_states_for_provider( + state.store.as_ref(), + provider.object_id(), + ) + .await + .unwrap(); + assert_eq!(after, vec![before]); + } + // Updating the chosen alias is still allowed. + handle_configure_provider_refresh(&state, authed_request(request)) + .await + .unwrap(); + } + } + + #[tokio::test] + async fn github_app_refresh_concurrent_alias_configuration_has_one_winner() { + let (state, _server, provider, first) = github_app_refresh_fixture().await; + let mut second = first.clone(); + second.credential_key = "GH_TOKEN".into(); + second + .material + .insert("installation_id".into(), "456".into()); + let (first, second) = tokio::join!( + handle_configure_provider_refresh(&state, authed_request(first)), + handle_configure_provider_refresh(&state, authed_request(second)), + ); + assert_ne!(first.is_ok(), second.is_ok()); + let err = first.err().or_else(|| second.err()).unwrap(); + assert_eq!(err.code(), Code::FailedPrecondition); + assert_eq!( + crate::provider_refresh::list_refresh_states_for_provider( + state.store.as_ref(), + provider.object_id(), + ) + .await + .unwrap() + .len(), + 1 + ); + } + + #[tokio::test] + async fn github_app_refresh_legacy_conflicts_remain_inspectable_and_deletable() { + for legacy_keys in [vec!["api_token"], vec!["GITHUB_TOKEN", "GH_TOKEN"]] { + let (state, server, provider, request) = github_app_refresh_fixture().await; + // Seed records that the old configure handler allowed. No shared + // secret handles: cleanup must target only the selected record. + for key in &legacy_keys { + let legacy = crate::provider_refresh::new_refresh_state( + &provider, + "default", + key, + crate::provider_refresh::NewRefreshStateConfig { + strategy: ProviderCredentialRefreshStrategy::GithubAppInstallation, + material: request.material.clone(), + secret_material_keys: vec!["private_key".into()], + expires_at_ms: 0, + token_url: format!( + "{}/app/installations/{{installation_id}}/access_tokens", + server.uri() + ), + scopes: Vec::new(), + refresh_before: None, + max_lifetime: None, + additional_output_keys: HashMap::new(), + }, + ) + .unwrap(); + crate::provider_refresh::put_refresh_state(state.store.as_ref(), &legacy) + .await + .unwrap(); + } + let err = handle_configure_provider_refresh(&state, authed_request(request.clone())) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + assert!(err.message().contains("exact key")); + let err = handle_rotate_provider_credential( + &state, + authed_request(RotateProviderCredentialRequest { + provider: provider.object_name().into(), + credential_key: "api_token".into(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + ..Default::default() + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + assert!(server.received_requests().await.unwrap().is_empty()); + let status_request = GetProviderRefreshStatusRequest { + provider: provider.object_name().into(), + credential_key: String::new(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + }; + let listed = + handle_get_provider_refresh_status(&state, authed_request(status_request.clone())) + .await + .unwrap() + .into_inner(); + assert_eq!(listed.credentials.len(), legacy_keys.len()); + let err = handle_get_provider_refresh_status( + &state, + authed_request(GetProviderRefreshStatusRequest { + credential_key: "api_token".into(), + ..status_request + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + for key in &legacy_keys { + handle_delete_provider_refresh( + &state, + authed_request(DeleteProviderRefreshRequest { + provider: provider.object_name().into(), + credential_key: (*key).into(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + ..Default::default() + }), + ) + .await + .unwrap(); + assert!( + crate::provider_refresh::get_refresh_state( + state.store.as_ref(), + "default", + provider.object_id(), + key, + ) + .await + .unwrap() + .is_none() + ); + } + handle_configure_provider_refresh(&state, authed_request(request)) + .await + .unwrap(); + } + } + + #[tokio::test] + async fn github_app_refresh_stores_key_rotates_token_and_expires_closed() { + use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding}; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, ResponseTemplate}; + + let (state, server, provider, request) = github_app_refresh_fixture().await; + let configure = || request.clone(); + let mut invalid = configure(); + invalid + .material + .insert("repository_ids".into(), "[]".into()); + assert_eq!( + handle_configure_provider_refresh(&state, authed_request(invalid)) + .await + .unwrap_err() + .code(), + Code::InvalidArgument + ); + handle_configure_provider_refresh(&state, authed_request(configure())) + .await + .unwrap(); + let initial = crate::provider_refresh::get_refresh_state( + state.store.as_ref(), + "default", + provider.object_id(), + "GITHUB_TOKEN", + ) + .await + .unwrap() + .unwrap(); + assert!(!initial.material.contains_key("private_key")); + assert!(initial.secret_material_handles.contains_key("private_key")); + + for token in ["installation-token-first", "installation-token-second"] { + server.reset().await; + Mock::given(method("POST")) + .and(path("/app/installations/123/access_tokens")) + .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({ + "token": token, + "expires_at": (chrono::Utc::now() + chrono::Duration::minutes(30)).to_rfc3339() + }))) + .expect(1) + .mount(&server) + .await; + let rotated = crate::provider_refresh::refresh_provider_credential( + state.store.as_ref(), + "default", + &state.credentials, + None, + "github-app-test", + "GITHUB_TOKEN", + ) + .await + .unwrap(); + assert_eq!(rotated.authorization_epoch, initial.authorization_epoch); + assert!(rotated.next_refresh_at_ms < rotated.expires_at_ms); + let stored = state + .store + .get_message_by_name::("default", "github-app-test") + .await + .unwrap() + .unwrap(); + assert!(stored.credentials.is_empty()); + assert_eq!(stored.credential_handles.len(), 1); + let resolved = state + .credentials + .resolve_provider_handles(&stored, crate::persistence::current_time_ms()) + .await + .unwrap(); + assert_eq!( + resolved.values.get("GITHUB_TOKEN").map(String::as_str), + Some(token) + ); + let expired = state + .credentials + .resolve_provider_handles(&stored, rotated.expires_at_ms + 1) + .await + .unwrap(); + assert!(expired.values.is_empty()); + } + // Replacing a valid signing key starts a new authorization epoch and + // stores new secret material; no old key should be reused for the mint. + let new_key = rsa::RsaPrivateKey::new(&mut rsa::rand_core::OsRng, 2048).unwrap(); + let mut replacement = configure(); + replacement.material.insert( + "private_key".into(), + new_key.to_pkcs8_pem(LineEnding::LF).unwrap().to_string(), + ); + handle_configure_provider_refresh(&state, authed_request(replacement)) + .await + .unwrap(); + let updated = crate::provider_refresh::get_refresh_state( + state.store.as_ref(), + "default", + provider.object_id(), + "GITHUB_TOKEN", + ) + .await + .unwrap() + .unwrap(); + assert_ne!(updated.authorization_epoch, initial.authorization_epoch); + assert_ne!( + updated.secret_material_handles, + initial.secret_material_handles + ); + assert!(!updated.material.contains_key("private_key")); + server.reset().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(401).set_body_string("secret echoed by issuer")) + .mount(&server) + .await; + assert!( + crate::provider_refresh::refresh_provider_credential( + state.store.as_ref(), + "default", + &state.credentials, + None, + "github-app-test", + "GITHUB_TOKEN" + ) + .await + .is_err() + ); + let failed = crate::provider_refresh::get_refresh_state( + state.store.as_ref(), + "default", + provider.object_id(), + "GITHUB_TOKEN", + ) + .await + .unwrap() + .unwrap(); + assert_eq!(failed.failure_code, "github_app_authentication_failed"); + assert!(!failed.last_error.contains("secret echoed")); + let public = new_key + .to_public_key() + .to_public_key_pem(LineEnding::LF) + .unwrap(); + let requests = server.received_requests().await.unwrap(); + let jwt = requests[0].headers["authorization"] + .to_str() + .unwrap() + .strip_prefix("Bearer ") + .unwrap(); + jsonwebtoken::decode::( + jwt, + &jsonwebtoken::DecodingKey::from_rsa_pem(public.as_bytes()).unwrap(), + &jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256), + ) + .unwrap(); + } + #[tokio::test] async fn configure_provider_refresh_stores_scoped_status_and_provider_expiry() { let state = test_server_state().await; diff --git a/crates/openshell-server/src/provider_refresh.rs b/crates/openshell-server/src/provider_refresh.rs index e8b39cbac4..39211138d3 100644 --- a/crates/openshell-server/src/provider_refresh.rs +++ b/crates/openshell-server/src/provider_refresh.rs @@ -22,6 +22,11 @@ use std::time::Duration; use tonic::{Code, Status}; use tracing::{info, warn}; +mod github_app; +pub use github_app::validate_configuration as validate_github_app_configuration; +#[cfg(test)] +pub use tests::TEST_RSA_PRIVATE_KEY; + use crate::storage_proto::{ StoredProviderCredentialRefreshStateV2 as StoredProviderCredentialRefreshState, StoredRefreshMaterialDeletion, @@ -659,6 +664,7 @@ pub fn refresh_strategy_name(strategy: i32) -> &'static str { ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => "oauth2_client_credentials", ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => "google_service_account_jwt", ProviderCredentialRefreshStrategy::AwsStsAssumeRole => "aws_sts_assume_role", + ProviderCredentialRefreshStrategy::GithubAppInstallation => "github_app_installation", ProviderCredentialRefreshStrategy::Unspecified => "unspecified", } } @@ -675,7 +681,8 @@ pub fn strategy_secret_material_keys( &["refresh_token", "client_secret"] } ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => &["client_secret"], - ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => &["private_key"], + ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt + | ProviderCredentialRefreshStrategy::GithubAppInstallation => &["private_key"], ProviderCredentialRefreshStrategy::AwsStsAssumeRole => { &["aws_secret_access_key", "aws_session_token"] } @@ -1364,6 +1371,7 @@ async fn mint_credential( ProviderCredentialRefreshStrategy::AwsStsAssumeRole => { mint_aws_sts_assume_role(state).await } + ProviderCredentialRefreshStrategy::GithubAppInstallation => github_app::mint(state).await, ProviderCredentialRefreshStrategy::External | ProviderCredentialRefreshStrategy::Static | ProviderCredentialRefreshStrategy::Unspecified => Err(Status::failed_precondition( @@ -4583,7 +4591,7 @@ mod tests { } } - const TEST_RSA_PRIVATE_KEY: &str = r"-----BEGIN PRIVATE KEY----- + pub const TEST_RSA_PRIVATE_KEY: &str = r"-----BEGIN PRIVATE KEY----- MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCvCoZ0mVHpCHsF zeeqw2caNIe/eb4BQUccFPhZfRnF7sCfyB84zTBmuwG2umRBdjFnVsfIIZRp2HcD OESrRYYiE1RGfjBXImGVg2Wtza0HYhL1sLyX1eaEefylxoilmApAgWDh9p36h8J2 diff --git a/crates/openshell-server/src/provider_refresh/github_app.rs b/crates/openshell-server/src/provider_refresh/github_app.rs new file mode 100644 index 0000000000..43306ad2c5 --- /dev/null +++ b/crates/openshell-server/src/provider_refresh/github_app.rs @@ -0,0 +1,666 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! GitHub App installation credentials. Signing material never leaves the gateway. + +use super::{ + MintedCredential, RefreshFailure, StoredProviderCredentialRefreshState, current_time_ms, + is_loopback_host, max_lifetime_seconds, required_material, +}; +use reqwest::{StatusCode, Url, header::HeaderMap}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::time::Duration; +use tonic::Status; + +const INSTALLATION_PATH: &str = "/app/installations/{installation_id}/access_tokens"; +const INSTALLATION_ID_PLACEHOLDER: &str = "{installation_id}"; +// GitHub may include metadata for up to 500 repositories in its response. +const MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024; + +#[derive(Serialize)] +struct InstallationRequest { + repository_ids: Vec, + permissions: BTreeMap, +} + +#[derive(Serialize)] +struct AppClaims<'a> { + iss: &'a str, + iat: i64, + exp: i64, +} + +#[derive(Deserialize)] +struct InstallationResponse { + token: String, + expires_at: String, +} + +fn installation_request(material: &HashMap) -> Result { + let repository_ids: Vec = + serde_json::from_str(&required_material(material, "repository_ids")?).map_err(|_| { + Status::invalid_argument("repository_ids must be a JSON array of positive integers") + })?; + if repository_ids.is_empty() + || repository_ids.len() > 500 + || repository_ids + .iter() + .any(|id| *id == 0 || *id > i64::MAX as u64) + || repository_ids.iter().collect::>().len() != repository_ids.len() + { + return Err(Status::invalid_argument( + "repository_ids must contain 1 to 500 distinct positive int64 IDs", + )); + } + let permissions: BTreeMap = + serde_json::from_str(&required_material(material, "permissions")?).map_err(|_| { + Status::invalid_argument( + "permissions must be a JSON object of permission names and levels", + ) + })?; + if permissions.is_empty() + || permissions.iter().any(|(name, level)| { + name.is_empty() + || !name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte == b'_') + || !matches!(level.as_str(), "read" | "write" | "admin") + }) + { + return Err(Status::invalid_argument( + "permissions must explicitly name permissions with read, write, or admin levels", + )); + } + Ok(InstallationRequest { + repository_ids, + permissions, + }) +} + +fn installation_url(token_url: &str, material: &HashMap) -> Result { + let installation_id = required_material(material, "installation_id")? + .parse::() + .ok() + .filter(|id| *id > 0 && i64::try_from(*id).is_ok()) + .ok_or_else(|| Status::invalid_argument("installation_id must be a positive int64 ID"))?; + if !token_url.ends_with(INSTALLATION_PATH) + || token_url.matches(INSTALLATION_ID_PLACEHOLDER).count() != 1 + { + return Err(Status::invalid_argument(format!( + "profile token_url must end with {INSTALLATION_PATH}" + ))); + } + let url = + Url::parse(&token_url.replace(INSTALLATION_ID_PLACEHOLDER, &installation_id.to_string())) + .map_err(|_| Status::invalid_argument("profile token_url must be an absolute URL"))?; + if url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || !(url.scheme() == "https" + || (url.scheme() == "http" && url.host_str().is_some_and(is_loopback_host))) + { + return Err(Status::invalid_argument( + "profile token_url requires HTTPS without userinfo, query, or fragment (loopback HTTP is allowed for tests)", + )); + } + Ok(url) +} + +pub fn validate_configuration( + material: &HashMap, + token_url: &str, +) -> Result<(), Status> { + // Reject misspelled scope fields and endpoint overrides rather than silently + // ignoring them. Repository and permission restrictions are always explicit. + if material.keys().any(|key| { + !matches!( + key.as_str(), + "client_id" + | "installation_id" + | "private_key" + | "repository_ids" + | "permissions" + | "refresh_before_seconds" + | "max_lifetime_seconds" + ) + }) { + return Err(Status::invalid_argument( + "unsupported github_app_installation material key", + )); + } + required_material(material, "client_id")?; + installation_request(material)?; + installation_url(token_url, material)?; + let private_key = required_material(material, "private_key")?; + jsonwebtoken::EncodingKey::from_rsa_pem(private_key.as_bytes()).map_err(|_| { + Status::invalid_argument("github_app_installation private_key must be RSA PEM") + })?; + Ok(()) +} + +pub(super) async fn mint( + state: &StoredProviderCredentialRefreshState, +) -> Result { + validate_configuration(&state.material, &state.token_url)?; + let url = installation_url(&state.token_url, &state.material)?; + let body = installation_request(&state.material)?; + let client_id = required_material(&state.material, "client_id")?; + let private_key = required_material(&state.material, "private_key")?; + crate::install_jsonwebtoken_crypto_provider(); + let now_secs = current_time_ms() / 1000; + let assertion = jsonwebtoken::encode( + &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256), + &AppClaims { + iss: &client_id, + iat: now_secs - 60, + exp: now_secs + 540, + }, + &jsonwebtoken::EncodingKey::from_rsa_pem(private_key.as_bytes()).map_err(|_| { + Status::invalid_argument("github_app_installation private_key must be RSA PEM") + })?, + ) + .map_err(|_| Status::invalid_argument("could not sign GitHub App JWT with private_key"))?; + + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .user_agent("OpenShell") + .build() + .map_err(|_| Status::internal("build GitHub App HTTP client failed"))?; + // Use the endpoint's default API version: GHES releases may not support + // the latest GitHub.com version. + let mut response = client + .post(url) + .bearer_auth(assertion) + .header("Accept", "application/vnd.github+json") + .json(&body) + .send() + .await + .map_err(transport_failure)?; + if response.status() != StatusCode::CREATED { + // Do not retain upstream prose: it can contain echoed credentials. + return Err(classify_error(response.status(), response.headers())); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|_| { + RefreshFailure::retryable( + Status::unavailable("could not read GitHub installation token response"), + "github_token_endpoint_unavailable", + ) + })? { + if bytes.len().saturating_add(chunk.len()) > MAX_RESPONSE_BYTES { + return Err(invalid_response()); + } + bytes.extend_from_slice(&chunk); + } + let token: InstallationResponse = + serde_json::from_slice(&bytes).map_err(|_| invalid_response())?; + // Treat tokens as opaque and accept the longer stateless installation token + // format. Reject whitespace/control characters before header substitution. + if token.token.is_empty() || !token.token.bytes().all(|byte| byte.is_ascii_graphic()) { + return Err(invalid_response()); + } + let expires_at_ms = chrono::DateTime::parse_from_rfc3339(&token.expires_at) + .map_err(|_| invalid_response())? + .timestamp_millis(); + let now_ms = current_time_ms(); + if expires_at_ms <= now_ms { + return Err(invalid_response()); + } + Ok(MintedCredential { + access_token: token.token, + expires_at_ms: expires_at_ms + .min(now_ms.saturating_add(max_lifetime_seconds(state).min(3600).saturating_mul(1000))), + refresh_token: None, + additional_credentials: HashMap::new(), + }) +} + +fn transport_failure(error: reqwest::Error) -> RefreshFailure { + // Inspect typed causes only. Formatting the source chain can expose endpoint + // or proxy URLs and peer-controlled text. io::Error can wrap a rustls error + // without exposing that wrapper's inner error through Error::source(). + let mut cause: Option<&(dyn std::error::Error + 'static)> = Some(&error); + let mut tls_error = None; + while let Some(current) = cause { + if let Some(tls) = current.downcast_ref::() { + tls_error = Some(tls); + break; + } + cause = current + .downcast_ref::() + .and_then(std::io::Error::get_ref) + .map_or_else(|| current.source(), |inner| Some(inner)); + } + let (error_kind, message) = if error.is_timeout() { + ( + "timeout", + "GitHub installation token request timed out; check gateway outbound connectivity and proxy settings", + ) + } else if matches!(tls_error, Some(rustls::Error::InvalidCertificate(_))) { + ( + "tls_certificate", + "GitHub installation token TLS certificate validation failed; check the gateway CA trust store and endpoint certificate", + ) + } else if tls_error.is_some() { + ( + "tls", + "GitHub installation token TLS handshake failed; check gateway TLS and proxy settings", + ) + } else if error.is_connect() { + ( + "connect", + "GitHub installation token connection failed; check gateway DNS, outbound connectivity, proxy settings, and CA trust", + ) + } else { + ( + "request", + "GitHub installation token request failed before an HTTP response; check gateway outbound connectivity and proxy settings", + ) + }; + tracing::warn!(error_kind, "GitHub installation token transport failed"); + RefreshFailure::retryable( + Status::unavailable(message), + "github_token_endpoint_unavailable", + ) +} + +fn invalid_response() -> RefreshFailure { + RefreshFailure::investigate( + Status::failed_precondition("GitHub returned an invalid installation token or expiry"), + "github_invalid_success_response", + ) +} + +fn classify_error(status: StatusCode, headers: &HeaderMap) -> RefreshFailure { + if status == StatusCode::TOO_MANY_REQUESTS + || (status == StatusCode::FORBIDDEN + && (headers.contains_key("retry-after") + || headers + .get("x-ratelimit-remaining") + .is_some_and(|value| value == "0"))) + || status.is_server_error() + { + return RefreshFailure::retryable( + Status::unavailable("GitHub token minting is temporarily unavailable or rate limited"), + "github_token_endpoint_retryable", + ); + } + let (message, code) = match status { + StatusCode::UNAUTHORIZED => ( + "GitHub rejected the app JWT; check client ID, private key, and gateway clock", + "github_app_authentication_failed", + ), + StatusCode::FORBIDDEN => ( + "GitHub denied installation token minting; check installation access and permissions", + "github_installation_forbidden", + ), + StatusCode::NOT_FOUND => ( + "GitHub installation was not found or is inaccessible to this app", + "github_installation_not_found", + ), + StatusCode::BAD_REQUEST => ( + "GitHub rejected the installation token request; check endpoint/API compatibility and request configuration", + "github_installation_request_invalid", + ), + StatusCode::UNPROCESSABLE_ENTITY => ( + "GitHub rejected the requested repositories or permissions", + "github_installation_scope_invalid", + ), + _ => ( + "GitHub returned an unexpected token endpoint status; check the profile endpoint", + "github_token_endpoint_invalid", + ), + }; + RefreshFailure::fix_configuration(Status::failed_precondition(message), code) +} + +#[cfg(test)] +mod tests { + use super::super::tests::TEST_RSA_PRIVATE_KEY; + use super::*; + use openshell_core::proto::ProviderCredentialRefreshRecoveryAction as Recovery; + use rsa::pkcs8::{DecodePrivateKey, EncodePublicKey}; + use wiremock::matchers::{body_json, header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn material() -> HashMap { + HashMap::from([ + ("client_id".into(), "Iv1.test-client".into()), + ("installation_id".into(), "123".into()), + ("private_key".into(), TEST_RSA_PRIVATE_KEY.into()), + ("repository_ids".into(), "[42,43]".into()), + ("permissions".into(), r#"{"contents":"read"}"#.into()), + ]) + } + + fn state(base: &str) -> StoredProviderCredentialRefreshState { + StoredProviderCredentialRefreshState { + material: material(), + token_url: format!("{base}{INSTALLATION_PATH}"), + ..Default::default() + } + } + + #[test] + fn github_app_requires_explicit_valid_scope_and_key() { + let url = format!("https://api.github.com{INSTALLATION_PATH}"); + validate_configuration(&material(), &url).unwrap(); + for key in [ + "client_id", + "installation_id", + "private_key", + "repository_ids", + "permissions", + ] { + let mut input = material(); + input.remove(key); + assert!( + validate_configuration(&input, &url).is_err(), + "missing {key}" + ); + } + for (key, value) in [ + ("installation_id", "../1"), + ("installation_id", "0"), + ("private_key", "secret-invalid-pem"), + ("repository_ids", "[]"), + ("repository_ids", "[0]"), + ("repository_ids", "[42,42]"), + ("repository_ids", "[-1]"), + ("repository_ids", "[1.5]"), + ("repository_ids", "[\"42\"]"), + ("permissions", "{}"), + ("permissions", "null"), + ("permissions", r#"{"contents":"all"}"#), + ("api_base_url", "https://other.example"), + ] { + let mut input = material(); + input.insert(key.into(), value.into()); + let error = validate_configuration(&input, &url).unwrap_err(); + assert!(!error.message().contains("secret-invalid-pem")); + } + let mut input = material(); + input.insert( + "repository_ids".into(), + serde_json::to_string(&(1..=501).collect::>()).unwrap(), + ); + assert!(validate_configuration(&input, &url).is_err()); + } + + #[test] + fn github_app_endpoint_is_profile_owned_and_keeps_enterprise_prefix() { + assert_eq!( + installation_url( + &format!("https://github.example/api/v3{INSTALLATION_PATH}"), + &material() + ) + .unwrap() + .path(), + "/api/v3/app/installations/123/access_tokens" + ); + for base in [ + "http://github.example", + "https://user:password@github.example", + "https://github.example?query=", + "https://github.example#", + ] { + assert!(installation_url(&format!("{base}{INSTALLATION_PATH}"), &material()).is_err()); + } + assert!(installation_url("https://api.github.com/token", &material()).is_err()); + } + + #[tokio::test] + async fn github_app_mints_scoped_token_with_verified_app_jwt() { + let server = MockServer::start().await; + let expiry = chrono::Utc::now() + chrono::Duration::minutes(30); + let token = format!("ghs_123_{}", "opaque".repeat(100)); + Mock::given(method("POST")) + .and(path("/app/installations/123/access_tokens")) + .and(header("accept", "application/vnd.github+json")) + .and(header("user-agent", "OpenShell")) + .and(body_json( + serde_json::json!({"repository_ids": [42,43], "permissions": {"contents":"read"}}), + )) + .respond_with(ResponseTemplate::new(201).set_body_json( + serde_json::json!({"token": token, "expires_at": expiry.to_rfc3339()}), + )) + .expect(1) + .mount(&server) + .await; + let before = current_time_ms() / 1000; + let minted = mint(&state(&server.uri())).await.unwrap(); + assert_eq!(minted.access_token, token); + assert_eq!(minted.expires_at_ms, expiry.timestamp_millis()); + assert!(minted.refresh_token.is_none()); + assert!(minted.additional_credentials.is_empty()); + let requests = server.received_requests().await.unwrap(); + let jwt = requests[0].headers["authorization"] + .to_str() + .unwrap() + .strip_prefix("Bearer ") + .unwrap(); + let key = rsa::RsaPrivateKey::from_pkcs8_pem(TEST_RSA_PRIVATE_KEY).unwrap(); + let public = key + .to_public_key() + .to_public_key_pem(rsa::pkcs8::LineEnding::LF) + .unwrap(); + let claims = jsonwebtoken::decode::( + jwt, + &jsonwebtoken::DecodingKey::from_rsa_pem(public.as_bytes()).unwrap(), + &jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256), + ) + .unwrap() + .claims; + assert_eq!(claims["iss"], "Iv1.test-client"); + assert!(claims["iat"].as_i64().unwrap() >= before - 60); + assert!(claims["iat"].as_i64().unwrap() <= current_time_ms() / 1000 - 60); + assert!(claims["exp"].as_i64().unwrap() <= current_time_ms() / 1000 + 600); + assert!(!String::from_utf8_lossy(&requests[0].body).contains("PRIVATE KEY")); + } + + #[tokio::test] + async fn github_app_mints_using_enterprise_default_api_version() { + let server = MockServer::start().await; + let expiry = chrono::Utc::now() + chrono::Duration::minutes(30); + Mock::given(method("POST")) + .and(path("/api/v3/app/installations/123/access_tokens")) + .respond_with(move |request: &wiremock::Request| { + if request + .headers + .get("x-github-api-version") + .is_some_and(|version| version != "2022-11-28") + { + return ResponseTemplate::new(400).set_body_json( + serde_json::json!({"message": "Not a supported version"}), + ); + } + ResponseTemplate::new(201).set_body_json( + serde_json::json!({"token": "enterprise-token", "expires_at": expiry.to_rfc3339()}), + ) + }) + .expect(1) + .mount(&server) + .await; + let minted = mint(&state(&format!("{}/api/v3", server.uri()))) + .await + .unwrap(); + assert_eq!(minted.access_token, "enterprise-token"); + assert_eq!(minted.expires_at_ms, expiry.timestamp_millis()); + let requests = server.received_requests().await.unwrap(); + assert!(!requests[0].headers.contains_key("x-github-api-version")); + } + + #[tokio::test] + async fn github_app_bad_request_is_not_misreported_as_invalid_scope() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "message": "Not a supported version; echoed secret-token" + }))) + .expect(1) + .mount(&server) + .await; + let failure = mint(&state(&server.uri())).await.unwrap_err(); + assert_eq!(failure.failure_code, "github_installation_request_invalid"); + assert_eq!(failure.recovery_action, Recovery::FixConfiguration); + assert_eq!(failure.status.code(), tonic::Code::FailedPrecondition); + assert_eq!( + failure.status.message(), + "GitHub rejected the installation token request; check endpoint/API compatibility and request configuration" + ); + } + + #[tokio::test] + async fn github_app_reports_untrusted_tls_without_exposing_request_material() { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let certificate = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap(); + let config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert( + vec![certificate.cert.der().clone()], + rustls::pki_types::PrivateKeyDer::Pkcs8( + certificate.key_pair.serialize_der().into(), + ), + ) + .unwrap(); + let acceptor = tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(config)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + openshell_core::net::set_tcp_nodelay_best_effort(&stream); + assert!(acceptor.accept(stream).await.is_err()); + }); + let failure = mint(&state(&format!("https://{address}"))) + .await + .unwrap_err(); + assert_eq!(failure.failure_code, "github_token_endpoint_unavailable"); + assert_eq!(failure.recovery_action, Recovery::Retry); + assert_eq!( + failure.status.message(), + "GitHub installation token TLS certificate validation failed; check the gateway CA trust store and endpoint certificate" + ); + server.await.unwrap(); + } + + #[tokio::test] + async fn github_app_distinguishes_connection_failures_and_timeouts() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + // A bound socket that never serves HTTP produces a real request timeout. + let client = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_millis(50)) + .build() + .unwrap(); + let url = format!("http://{address}/secret-path?secret-query=value"); + let failure = transport_failure(client.get(&url).send().await.unwrap_err()); + assert_eq!(failure.recovery_action, Recovery::Retry); + assert!(failure.status.message().contains("timed out")); + assert!(!failure.status.message().contains("secret")); + + drop(listener); + let client = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + let failure = transport_failure(client.get(&url).send().await.unwrap_err()); + assert_eq!(failure.failure_code, "github_token_endpoint_unavailable"); + assert_eq!(failure.recovery_action, Recovery::Retry); + assert!(failure.status.message().contains("connection failed")); + assert!(!failure.status.message().contains("secret")); + } + + #[tokio::test] + async fn github_app_rejects_invalid_success_without_echoing_secrets() { + let server = MockServer::start().await; + for body in [ + serde_json::json!({"token":"secret-token", "expires_at":"2000-01-01T00:00:00Z"}), + serde_json::json!({"token":"secret-token", "expires_at":"invalid"}), + serde_json::json!({"token":"secret-token\r\nInjected: value", "expires_at":"2099-01-01T00:00:00Z"}), + serde_json::json!({"token":"", "expires_at":"2099-01-01T00:00:00Z"}), + serde_json::json!({"token":"secret-token"}), + ] { + server.reset().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(201).set_body_json(body)) + .mount(&server) + .await; + let error = mint(&state(&server.uri())).await.unwrap_err(); + assert_eq!(error.failure_code, "github_invalid_success_response"); + assert!(!error.status.message().contains("secret-token")); + } + } + + #[tokio::test] + async fn github_app_caps_lifetime_and_never_follows_redirects() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(201).set_body_json( + serde_json::json!({"token":"token", "expires_at":"2099-01-01T00:00:00Z"}), + )) + .mount(&server) + .await; + let mut input = state(&server.uri()); + input.max_lifetime = Some(prost_types::Duration { + seconds: 120, + nanos: 0, + }); + let minted = mint(&input).await.unwrap(); + assert!(minted.expires_at_ms <= current_time_ms() + 120_000); + assert!(minted.expires_at_ms > current_time_ms()); + server.reset().await; + let redirect_target = MockServer::start().await; + Mock::given(method("POST")) + .respond_with( + ResponseTemplate::new(307).insert_header("location", redirect_target.uri()), + ) + .mount(&server) + .await; + assert_eq!( + mint(&input).await.unwrap_err().failure_code, + "github_token_endpoint_invalid" + ); + assert!( + redirect_target + .received_requests() + .await + .unwrap() + .is_empty() + ); + } + + #[test] + fn github_app_classifies_errors_without_provider_prose() { + assert_eq!( + classify_error(StatusCode::UNPROCESSABLE_ENTITY, &HeaderMap::new()).failure_code, + "github_installation_scope_invalid" + ); + for code in [400, 401, 403, 404, 422] { + assert_eq!( + classify_error(StatusCode::from_u16(code).unwrap(), &HeaderMap::new()) + .recovery_action, + Recovery::FixConfiguration + ); + } + for code in [429, 500, 503] { + assert_eq!( + classify_error(StatusCode::from_u16(code).unwrap(), &HeaderMap::new()) + .recovery_action, + Recovery::Retry + ); + } + let mut headers = HeaderMap::new(); + headers.insert("x-ratelimit-remaining", "0".parse().unwrap()); + assert_eq!( + classify_error(StatusCode::FORBIDDEN, &headers).recovery_action, + Recovery::Retry + ); + } +} diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index 16a733415c..2988c32a79 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -118,14 +118,14 @@ mod tests { const STORAGE_V1_SCHEMA_SHA256: &str = "d68401809d8cea445c35233ef32412bbd041cb2ac5acaf368a0d0bf74d2ddf17"; - // Carries this branch's exec request IDs together with main's opaque watch - // cursor and well-known time types. These unreleased public-only fields add - // no messages or enums and touch no stored type, so the durable and overlap - // fingerprints below remain unchanged. + // GitHub App refresh adds enum value 7 to the public refresh strategy, + // also referenced by durable provider profiles. Existing numbers and + // fields are unchanged, so prior payloads retain their meaning. The frozen + // storage V1 schema and the set of public/durable type names are unchanged. const PUBLIC_RPC_SCHEMA_SHA256: &str = - "8fb59b0932ec2f227fdec2d46b6204925e79595695810a247bef731ddd632594"; + "fd5f72f21e54dcb115e4efed3699b21372dcc960a23fed5849b5e4349747008c"; const DURABLE_SCHEMA_SHA256: &str = - "9eeaa29dfba187bff69fb7bc4f9a13a0f1d7be3f7049a38c8f0e20ce77ec7d8b"; + "4870d9656aa88f0230bdef85f6c65a48ddc9e1b5c8e54668d19f8e5938805b30"; const PUBLIC_DURABLE_OVERLAP_SHA256: &str = "a6e97fdde30c439ffaa03c2952a43033f8ea338fed6b1456ebe2d7d8af14e834"; // A persisted Sandbox without endpoint status retains its lifecycle fields; diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 7a53343142..00480478ce 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -847,6 +847,9 @@ fn refresh_strategy_label(strategy: i32) -> &'static str { openshell_core::proto::ProviderCredentialRefreshStrategy::AwsStsAssumeRole => { "aws_sts_assume_role" } + openshell_core::proto::ProviderCredentialRefreshStrategy::GithubAppInstallation => { + "github_app_installation" + } openshell_core::proto::ProviderCredentialRefreshStrategy::Unspecified => "unspecified", } } diff --git a/docs/providers/profiles.mdx b/docs/providers/profiles.mdx index 446715a5c6..85772fb81a 100644 --- a/docs/providers/profiles.mdx +++ b/docs/providers/profiles.mdx @@ -497,8 +497,9 @@ Profile YAML can declare these refresh strategies: | `oauth2_client_credentials` | The gateway mints a short-lived access token with OAuth2 client credentials. | | `google_service_account_jwt` | The gateway signs a Google service account JWT and exchanges it for an access token. | | `aws_sts_assume_role` | The gateway calls `sts:AssumeRole` and mints `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_SESSION_TOKEN` in one operation. | +| `github_app_installation` | The gateway signs a GitHub App JWT and mints an installation token restricted to explicit repositories and permissions. | -`openshell provider refresh configure` accepts only gateway-mintable strategies: `oauth2-refresh-token`, `oauth2-client-credentials`, `google-service-account-jwt`, and `aws-sts-assume-role`. Use `openshell provider update` for `static` and `external` refresh patterns. +`openshell provider refresh configure` accepts only gateway-mintable strategies: `oauth2-refresh-token`, `oauth2-client-credentials`, `google-service-account-jwt`, `aws-sts-assume-role`, and `github-app-installation`. Use `openshell provider update` for `static` and `external` refresh patterns. Gateway-managed refresh strategies use these material keys: @@ -508,9 +509,112 @@ Gateway-managed refresh strategies use these material keys: | `oauth2_client_credentials` | `client_id`, `client_secret`, optional `tenant_id` for Microsoft Entra token URLs. | | `google_service_account_jwt` | `client_email`, `private_key`, optional `subject` or `sub`. | | `aws_sts_assume_role` | `role_arn`, optional `session_name`, `external_id`, `aws_region`, and optional long-lived `aws_access_key_id` / `aws_secret_access_key` for gateways without ambient AWS credentials. | +| `github_app_installation` | `client_id`, `installation_id`, `private_key`, `repository_ids` (JSON array), and `permissions` (JSON object). | OpenShell keeps token endpoints profile-owned. Refresh material cannot override `token_url` or `token_uri` during refresh configuration. +### GitHub App Installation Tokens + +Use a GitHub App provider when agents should act as an app installation. Install +the app on the intended account and grant it access to the required repositories +and permissions. Each provider represents one installation and a fixed access +scope. Use separate providers for different scopes. + +Download the [GitHub App example profile](https://github.com/NVIDIA/OpenShell/blob/main/providers/github-app.yaml) +as `github-app.yaml` and adapt its binary paths to your sandbox image. The profile +declares `github_app_installation` refresh on its token credential. Its +`token_url` is `https://api.github.com/app/installations/{installation_id}/access_tokens`. +The gateway substitutes the numeric installation ID. The endpoint must use HTTPS +and cannot contain userinfo, query parameters, or a fragment. Loopback HTTP is +allowed for local tests. The gateway does not follow redirects. + +For GitHub Enterprise Server, set `token_url` to +`https://HOSTNAME/api/v3/app/installations/{installation_id}/access_tokens` +and adapt the profile's API and Git host rules to your instance. The gateway +omits `X-GitHub-Api-Version` and uses the endpoint's default REST API version. + +Load the app's RSA PEM key into the CLI environment, then configure refresh. +Replace the example IDs with your app client ID, installation ID, and repository IDs. + +```shell +export GITHUB_APP_PRIVATE_KEY="$(cat /secure/path/github-app.pem)" +openshell provider profile import -f github-app.yaml +openshell provider create --name repo-reader --type github-app --runtime-credentials +openshell provider refresh configure repo-reader \ + --credential-key GITHUB_TOKEN \ + --strategy github-app-installation \ + --material client_id=Iv1.example \ + --material installation_id=12345 \ + --material 'repository_ids=[123456789]' \ + --material 'permissions={"contents":"read"}' \ + --secret-material-env private_key=GITHUB_APP_PRIVATE_KEY +unset GITHUB_APP_PRIVATE_KEY +openshell provider refresh rotate repo-reader --credential-key GITHUB_TOKEN +openshell provider refresh status repo-reader +openshell sandbox create --provider repo-reader +``` + +Configuration stores refresh material and schedules minting. The explicit +`rotate` command mints immediately, so check its result before creating a sandbox. +Choose either `GITHUB_TOKEN` or `GH_TOKEN` as the credential key. Only the chosen +environment variable is injected, as a placeholder. The private key remains in +the gateway's credential backend, and the app JWT stays at the gateway. + +The logical credential name `api_token` selects the configured alias, or +`GITHUB_TOKEN` when neither alias has a refresh configuration. Configure, rotate, +delete, and credential-filtered status use this resolution. Status responses +report the selected environment alias. A second refresh configuration under the +other alias is rejected, including when the existing configuration is pending or has +failed. Update the selected alias, or delete its refresh configuration before +switching aliases. + +Older gateways allowed refresh states under `api_token` or both environment +aliases. If these states cause a conflict, run +`openshell provider refresh status repo-reader` without a credential filter, +delete the conflicting refresh configurations using their exact reported keys, +and configure refresh again. + +Supply 1 to 500 distinct positive repository IDs and a nonempty permission map +using GitHub permission names and `read`, `write`, or `admin` levels. GitHub +validates whether the installation grants those permissions. OpenShell rejects +missing or empty scope rather than requesting installation-wide access. Use +`permissions`, not OAuth `scopes`. + +The gateway honors GitHub's returned expiry and caps local token use at one hour +or the configured shorter maximum lifetime. The example refreshes five minutes +before expiry. Routine refresh preserves workload handles, including bearer +headers used by API clients and Basic authentication used by Git HTTPS. Configure +Git's credential helper to use `x-access-token` as the username and the token +placeholder as its password. Git SSH authentication is separate. + +Token permissions and sandbox network policy both apply. The example profile +permits API reads and Git clone/fetch; minting a write-capable token does not +enable API writes or push. Installation tokens represent the app and do not +support every operation that requires a GitHub user identity. + +For key replacement or a scope change, configure refresh again with all material, +rotate, and restart processes holding the previous workload handle. +Reconfiguration starts a new authorization epoch. Transient issuer failures retry +through the existing refresh worker; configuration failures appear in refresh +status. Previously minted credentials remain subject to their recorded expiry, +and expired tokens fail closed. Revoking a key does not constitute explicit +revocation of already minted tokens in OpenShell. + +If rotation reports `github_token_endpoint_unavailable`, the gateway could not +complete the token endpoint request or read its response. Request failures +distinguish timeouts, TLS certificate validation, TLS handshakes, and connection +errors when the HTTP client provides a typed cause. Check DNS, outbound access to +`api.github.com:443`, proxy settings, and CA trust in the gateway's own process or +container. A successful request from your CLI host does not verify the gateway's +network or trust configuration. These transport failures are separate from GitHub +HTTP rejections of the app credentials, installation, or requested scope. + +On Kubernetes, the Helm chart sets the gateway's `SSL_CERT_FILE` when +`server.oidc.caConfigMapName` is configured. That override applies to all native +root loading in the gateway, including GitHub requests. Its bundle must contain +the CA roots needed for both the OIDC issuer and outbound provider endpoints; +a bundle containing only a private OIDC CA can prevent GitHub TLS validation. + ### Additional Outputs Most refresh strategies mint a single credential. `aws_sts_assume_role` mints three. A refresh declares the extra credentials it co-mints with `additional_outputs`, mapping each strategy-defined output id to a sibling credential whose `env_vars` receive the value: diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index 178ab92630..e5300a89ca 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -401,6 +401,8 @@ Variables set with `--env` are available to all processes in the sandbox, includ When an `--env` key looks like a credential — a known provider variable, or a name whose underscore-separated segments include a credential word such as `TOKEN`, `SECRET`, `PASSWORD`, `CREDENTIAL`, `API_KEY`, `ACCESS_KEY`, or `SECRET_KEY` (for example `DB_TOKEN` or `MY_ACCESS_KEY`) — `sandbox create` prints a non-blocking warning. Matching is on whole segments, so unrelated names like `TOKENIZERS_PARALLELISM` or `PASSWORDLESS_LOGIN` do not warn. The agent inside the sandbox can read plain environment values directly, so to hide a secret from the agent, attach it through a [profile-backed provider](/providers/profiles) with `--provider` instead. Suppress the warning with `--no-credential-warnings`. Detection uses the key name only; values are never inspected or printed. +Choose the suggested profile that matches how you authenticate. For example, when both profiles are available, `GITHUB_TOKEN` produces an existing-token option for `github` and a gateway-managed refresh option for `github-app`. The variable name does not identify the token's origin. The App option uses `--runtime-credentials` and requires [GitHub App refresh configuration](/providers/profiles#github-app-installation-tokens) before attaching the provider; storing an installation token as a static credential does not enable rotation. + You can also set per-command environment variables with `sandbox exec`: ```shell diff --git a/docs/sdk/go.mdx b/docs/sdk/go.mdx index 478280934d..b79a580c17 100644 --- a/docs/sdk/go.mdx +++ b/docs/sdk/go.mdx @@ -119,6 +119,11 @@ The root client also exposes subclients for providers, services, files, SSH, TCP forwarding, policy, configuration, templates, and workspaces. List methods return lazy pagers so callers choose when to fetch the next page. +For [GitHub App installation tokens](/providers/profiles#github-app-installation-tokens), +use `v1.RefreshStrategyGitHubAppInstallation` in `v1.RefreshConfig` with +`client.Providers().Refresh().Configure`. The constant is available from the +same `openshell/v1` import used above. + ## Next Steps - Review [Gateway Authentication](/reference/gateway-auth) before connecting a service to a production gateway. diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index 102f70c147..d4e2ea8e84 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -113,6 +113,11 @@ name = "provider_refresh_handles" path = "tests/provider_refresh_handles.rs" required-features = ["e2e-podman"] +[[test]] +name = "provider_github_app" +path = "tests/provider_github_app.rs" +required-features = ["e2e-podman"] + [[test]] name = "provider_readiness" path = "tests/provider_readiness.rs" diff --git a/e2e/rust/e2e-podman.sh b/e2e/rust/e2e-podman.sh index d4fa2a8103..bcf5596234 100755 --- a/e2e/rust/e2e-podman.sh +++ b/e2e/rust/e2e-podman.sh @@ -40,6 +40,7 @@ PODMAN_CI_TESTS=( podman_corporate_proxy podman_gateway_start podman_host_gateway + provider_github_app provider_token_exchange ) diff --git a/e2e/rust/src/harness/container.rs b/e2e/rust/src/harness/container.rs index a85c7747d0..86c0a3b7b2 100644 --- a/e2e/rust/src/harness/container.rs +++ b/e2e/rust/src/harness/container.rs @@ -396,6 +396,24 @@ impl HostSupportContainer { bindings: &[(u16, u16)], ready_port: u16, capabilities: &[&str], + ) -> Result { + Self::start_python_image_with_host_bindings( + E2E_WORKLOAD_IMAGE, + script, + bindings, + ready_port, + capabilities, + ) + .await + } + + /// Start a multi-port fixture using a caller-built tool image. + pub async fn start_python_image_with_host_bindings( + image: &str, + script: &str, + bindings: &[(u16, u16)], + ready_port: u16, + capabilities: &[&str], ) -> Result { let published_ready_port = bindings .iter() @@ -418,11 +436,7 @@ impl HostSupportContainer { .iter() .map(|capability| format!("--cap-add={capability}")), ); - args.extend([ - E2E_WORKLOAD_IMAGE.to_string(), - "-c".to_string(), - script.to_string(), - ]); + args.extend([image.to_string(), "-c".to_string(), script.to_string()]); let output = engine .command() .args(&args) diff --git a/e2e/rust/tests/provider_github_app.rs b/e2e/rust/tests/provider_github_app.rs new file mode 100644 index 0000000000..5faf71d67f --- /dev/null +++ b/e2e/rust/tests/provider_github_app.rs @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "e2e-podman")] + +#[path = "support/provider_refresh_handles.rs"] +mod support; + +const PROVIDER_NAME: &str = "e2e-github-app-refresh-handle"; +const PROFILE_ID: &str = "e2e-github-app-refresh-handle"; + +#[tokio::test] +async fn github_app_survives_rotations_and_reconfigure_revokes() -> Result<(), String> { + let key = std::process::Command::new("openssl") + .args([ + "genpkey", + "-algorithm", + "RSA", + "-pkeyopt", + "rsa_keygen_bits:2048", + ]) + .output() + .map_err(|error| format!("generate test app key: {error}"))?; + if !key.status.success() { + return Err("openssl could not generate the test GitHub App key".into()); + } + let key = String::from_utf8(key.stdout).map_err(|error| error.to_string())?; + support::exercise_refresh_handles(Some(&key)).await +} diff --git a/e2e/rust/tests/provider_refresh_handles.rs b/e2e/rust/tests/provider_refresh_handles.rs index 1de651415d..a34ae05621 100644 --- a/e2e/rust/tests/provider_refresh_handles.rs +++ b/e2e/rust/tests/provider_refresh_handles.rs @@ -1,364 +1,15 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 #![cfg(feature = "e2e-podman")] -//! Podman E2E coverage for refresh-managed workload credential handles. -//! -//! A fake issuer invalidates every prior access token. One long-running shell -//! retains its original environment while the gateway rotates the provider 12 -//! times. The shell must continue reaching the resource with the newest token, -//! and explicit refresh reconfiguration must revoke its old handle. +#[path = "support/provider_refresh_handles.rs"] +mod support; -use std::io::Write; -use std::process::Stdio; -use std::time::Duration; - -use openshell_e2e::harness::binary::openshell_cmd; -use openshell_e2e::harness::container::HostSupportContainer; -use openshell_e2e::harness::port::find_free_port; -use openshell_e2e::harness::sandbox::SandboxGuard; -use tempfile::{Builder as TempFileBuilder, NamedTempFile}; - -const PROVIDER_NAME: &str = "e2e-stable-refresh-handle"; -const PROFILE_ID: &str = "e2e-stable-refresh-handle"; -const TOKEN_ENV: &str = "REFRESH_E2E_ACCESS_TOKEN"; -const READY_MARKER: &str = "stable-refresh-parent-ready"; - -const FIXTURE_SCRIPT: &str = r#" -import json -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer - -current_token = "bootstrap-token" -generation = 0 - -class Handler(BaseHTTPRequestHandler): - def do_POST(self): - global current_token, generation - if self.path != "/token": - self.send_response(404) - self.end_headers() - return - generation += 1 - current_token = f"access-token-{generation}" - print(f"issued-token generation={generation}", flush=True) - body = json.dumps({ - "access_token": current_token, - "expires_in": 300, - "token_type": "Bearer", - }).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def do_GET(self): - authorized = self.headers.get("Authorization") == f"Bearer {current_token}" - print(f"resource-request path={self.path} authorized={authorized}", flush=True) - if self.path == "/": - self.send_response(204) - elif self.path == "/probe" and authorized: - self.send_response(204) - else: - self.send_response(401) - self.end_headers() - - def log_message(self, *_args): - pass - -ThreadingHTTPServer(("0.0.0.0", __PORT__), Handler).serve_forever() -"#; - -async fn run_cli(args: &[&str]) -> Result { - run_cli_with_env(args, &[]).await -} - -async fn run_cli_with_env(args: &[&str], env: &[(&str, &str)]) -> Result { - let mut command = openshell_cmd(); - command - .args(args) - .envs(env.iter().copied()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - let output = command - .output() - .await - .map_err(|error| format!("run openshell command: {error}"))?; - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - if !output.status.success() { - return Err(format!( - "openshell command failed (exit {:?}):\n{combined}", - output.status.code() - )); - } - Ok(combined) -} - -async fn delete_provider_resources() { - let _ = run_cli(&["provider", "delete", PROVIDER_NAME]).await; - let _ = run_cli(&["profile", "delete", PROFILE_ID]).await; -} - -fn write_profile(resource_port: u16, token_port: u16) -> Result { - let mut file = TempFileBuilder::new() - .suffix(".yaml") - .tempfile() - .map_err(|error| format!("create profile: {error}"))?; - let profile = format!( - r"id: {PROFILE_ID} -display_name: Stable refresh handle E2E -category: other -credentials: - - name: access_token - env_vars: [{TOKEN_ENV}] - required: true - auth_style: bearer - header_name: authorization - refresh: - strategy: oauth2_client_credentials - token_url: http://127.0.0.1:{token_port}/token - refresh_before_seconds: 30 - max_lifetime_seconds: 300 - material: - - name: client_id - required: true - - name: client_secret - required: true - secret: true -endpoints: - - host: host.openshell.internal - port: {resource_port} - path: /probe - protocol: rest - access: full - enforcement: enforce - allowed_ips: - - 10.0.0.0/8 - - 169.254.0.0/16 - - 172.0.0.0/8 - - 192.168.0.0/16 -binaries: - - /** -" - ); - file.write_all(profile.as_bytes()) - .map_err(|error| format!("write profile: {error}"))?; - file.flush() - .map_err(|error| format!("flush profile: {error}"))?; - Ok(file) -} - -fn write_policy(resource_port: u16) -> Result { - let mut file = TempFileBuilder::new() - .suffix(".yaml") - .tempfile() - .map_err(|error| format!("create policy: {error}"))?; - let policy = format!( - r"version: 1 -filesystem_policy: - include_workdir: true - read_only: [/usr, /lib, /proc, /etc, /dev/urandom] - read_write: [/sandbox, /tmp, /dev/null] -landlock: - compatibility: best_effort -process: - run_as_user: sandbox - run_as_group: sandbox -network_policies: - refresh_probe: - name: refresh_probe - endpoints: - - host: host.openshell.internal - port: {resource_port} - path: /probe - protocol: rest - access: full - enforcement: enforce - allowed_ips: - - 10.0.0.0/8 - - 169.254.0.0/16 - - 172.0.0.0/8 - - 192.168.0.0/16 - binaries: - - path: /** -" - ); - file.write_all(policy.as_bytes()) - .map_err(|error| format!("write policy: {error}"))?; - file.flush() - .map_err(|error| format!("flush policy: {error}"))?; - Ok(file) -} - -async fn configure_refresh(profile: &NamedTempFile) -> Result<(), String> { - let profile_path = profile.path().to_string_lossy().into_owned(); - run_cli(&["profile", "import", "--file", &profile_path]).await?; - run_cli_with_env( - &[ - "provider", - "create", - "--name", - PROVIDER_NAME, - "--type", - PROFILE_ID, - "--credential", - TOKEN_ENV, - ], - &[(TOKEN_ENV, "bootstrap-token")], - ) - .await?; - reconfigure_refresh().await -} - -async fn reconfigure_refresh() -> Result<(), String> { - run_cli_with_env( - &[ - "provider", - "refresh", - "configure", - PROVIDER_NAME, - "--credential-key", - TOKEN_ENV, - "--strategy", - "oauth2-client-credentials", - "--material", - "client_id=e2e-client", - "--secret-material-env", - "client_secret=REFRESH_E2E_CLIENT_SECRET", - ], - &[("REFRESH_E2E_CLIENT_SECRET", "e2e-client-secret")], - ) - .await - .map(|_| ()) -} - -async fn rotate() -> Result<(), String> { - run_cli(&[ - "provider", - "refresh", - "rotate", - PROVIDER_NAME, - "--credential-key", - TOKEN_ENV, - ]) - .await - .map(|_| ()) -} - -async fn trigger_probe(sandbox: &SandboxGuard) -> Result { - sandbox - .exec(&[ - "sh", - "-c", - "rm -f /sandbox/probe-result; touch /sandbox/probe-trigger", - ]) - .await?; - let deadline = tokio::time::Instant::now() + Duration::from_secs(10); - loop { - if let Ok(result) = sandbox.exec(&["cat", "/sandbox/probe-result"]).await { - return Ok(result.trim().to_string()); - } - if tokio::time::Instant::now() >= deadline { - return Err("timed out waiting for long-running credential probe".to_string()); - } - tokio::time::sleep(Duration::from_millis(100)).await; - } -} - -async fn wait_for_probe_success(sandbox: &SandboxGuard) -> Result<(), String> { - let deadline = tokio::time::Instant::now() + Duration::from_secs(45); - loop { - if trigger_probe(sandbox).await? == "ok" { - return Ok(()); - } - if tokio::time::Instant::now() >= deadline { - return Err("long-running process never resolved the latest rotated token".to_string()); - } - tokio::time::sleep(Duration::from_millis(500)).await; - } -} - -async fn wait_for_probe_failure(sandbox: &SandboxGuard) -> Result<(), String> { - let deadline = tokio::time::Instant::now() + Duration::from_secs(45); - loop { - if trigger_probe(sandbox).await? == "failed" { - return Ok(()); - } - if tokio::time::Instant::now() >= deadline { - return Err("old workload handle survived explicit reconfiguration".to_string()); - } - tokio::time::sleep(Duration::from_millis(500)).await; - } -} +const PROVIDER_NAME: &str = "e2e-oauth-refresh-handle"; +const PROFILE_ID: &str = "e2e-oauth-refresh-handle"; #[tokio::test] async fn long_running_process_survives_rotations_and_reconfigure_revokes() -> Result<(), String> { - delete_provider_resources().await; - let fixture_port = find_free_port(); - let fixture_script = FIXTURE_SCRIPT.replace("__PORT__", &fixture_port.to_string()); - let fixture = - HostSupportContainer::start_python_on_host_network(&fixture_script, fixture_port).await?; - let profile = write_profile(fixture.port, fixture.port)?; - let policy = write_policy(fixture.port)?; - configure_refresh(&profile).await?; - - let policy_path = policy.path().to_string_lossy().into_owned(); - let resource_url = format!("http://host.openshell.internal:{}/probe", fixture.port); - let parent_script = format!( - r#"case "$REFRESH_E2E_ACCESS_TOKEN" in - openshell:resolve:env:s*_REFRESH_E2E_ACCESS_TOKEN) ;; - *) exit 64 ;; -esac -echo {READY_MARKER} -while true; do - if [ -f /sandbox/probe-trigger ]; then - rm -f /sandbox/probe-trigger - if /usr/bin/python3 -c 'import os, urllib.request; request = urllib.request.Request("{resource_url}", headers=dict(Authorization="Bearer " + os.environ["REFRESH_E2E_ACCESS_TOKEN"])); urllib.request.urlopen(request, timeout=5).read()'; then - echo ok > /sandbox/probe-result - else - echo failed > /sandbox/probe-result - fi - fi - sleep 0.1 -done"# - ); - let mut sandbox = SandboxGuard::create_keep_with_args( - &["--provider", PROVIDER_NAME, "--policy", &policy_path], - &["sh", "-c", &parent_script], - READY_MARKER, - ) - .await?; - - let result = async { - if trigger_probe(&sandbox).await? != "ok" { - return Err(format!( - "initial long-running credential probe failed; fixture logs:\n{}", - fixture.logs().unwrap_or_else(|error| error) - )); - } - - for _ in 0..12 { - rotate().await?; - } - wait_for_probe_success(&sandbox).await?; - - reconfigure_refresh().await?; - wait_for_probe_failure(&sandbox).await?; - - let fresh_probe = format!( - r#"/usr/bin/python3 -c 'import os, urllib.request; request = urllib.request.Request("{resource_url}", headers=dict(Authorization="Bearer " + os.environ["REFRESH_E2E_ACCESS_TOKEN"])); urllib.request.urlopen(request, timeout=5).read()'"# - ); - sandbox.exec(&["sh", "-c", &fresh_probe]).await?; - Ok(()) - } - .await; - - sandbox.cleanup().await; - delete_provider_resources().await; - result + support::exercise_refresh_handles(None).await } diff --git a/e2e/rust/tests/support/github_app_fixture.py b/e2e/rust/tests/support/github_app_fixture.py new file mode 100644 index 0000000000..9d1b2e1b1d --- /dev/null +++ b/e2e/rust/tests/support/github_app_fixture.py @@ -0,0 +1,231 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Local installation-token issuer, authenticated REST API, and smart Git HTTPS.""" + +import base64 +import json +import os +import socket +import ssl +import subprocess +import tempfile +import threading +from datetime import UTC, datetime, timedelta +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import urlsplit + +root = Path(tempfile.mkdtemp()) +os.chdir(root) + + +def run(*args): + return subprocess.run(args, check=True, capture_output=True) + + +run( + "openssl", + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-days", + "1", + "-subj", + "/CN=OpenShell E2E CA", + "-keyout", + "ca.key", + "-out", + "ca.crt", + "-addext", + "basicConstraints=critical,CA:TRUE", +) +run( + "openssl", + "req", + "-newkey", + "rsa:2048", + "-nodes", + "-subj", + "/CN=FIXTURE_HOST", + "-keyout", + "server.key", + "-out", + "server.csr", +) +Path("server.ext").write_text( + "subjectAltName=IP:FIXTURE_HOST\nbasicConstraints=critical,CA:FALSE\nextendedKeyUsage=serverAuth\n" +) +run( + "openssl", + "x509", + "-req", + "-in", + "server.csr", + "-CA", + "ca.crt", + "-CAkey", + "ca.key", + "-CAcreateserial", + "-days", + "1", + "-extfile", + "server.ext", + "-out", + "server.crt", +) +print(Path("ca.crt").read_text(), flush=True) + +run("git", "init", "--initial-branch=main", "seed") +Path("seed/README.md").write_text("GitHub App installation token E2E\n") +run("git", "-C", "seed", "add", "README.md") +run( + "git", + "-C", + "seed", + "-c", + "user.name=E2E", + "-c", + "user.email=e2e@example.invalid", + "commit", + "-m", + "Initial fixture", +) +run("git", "clone", "--bare", "seed", "repo.git") + +current_token = "bootstrap-token" +generation = 0 +lock = threading.Lock() + + +class Handler(BaseHTTPRequestHandler): + def reply(self, status, body=b"", content_type="application/json"): + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def authorized(self): + with lock: + token = current_token + basic = "Basic " + base64.b64encode(f"x-access-token:{token}".encode()).decode() + if self.path == "/probe": + return self.headers.get("Authorization") in (f"Bearer {token}", basic) + # Require the proper auth style on each surface. Never log headers. + expected = basic if self.path.startswith("/repo.git/") else f"Bearer {token}" + return self.headers.get("Authorization") == expected + + def do_POST(self): + global current_token, generation + body = self.rfile.read(int(self.headers.get("Content-Length", "0"))) + if self.path == "/token" and self.server.server_port == 8000: + with lock: + generation += 1 + current_token = f"oauth-token-{generation}" + token = current_token + self.reply( + 200, + json.dumps( + {"access_token": token, "expires_in": 300, "token_type": "Bearer"} + ).encode(), + ) + return + if ( + self.path == "/app/installations/123/access_tokens" + and self.server.server_port == 8000 + ): + if json.loads(body) != { + "repository_ids": [42], + "permissions": {"contents": "read"}, + } or not self.headers.get("Authorization", "").startswith("Bearer ey"): + self.reply(422) + return + with lock: + generation += 1 + current_token = f"installation-token-{generation}" + token = current_token + self.reply( + 201, + json.dumps( + { + "token": token, + "expires_at": ( + datetime.now(UTC) + timedelta(seconds=300) + ).isoformat(), + } + ).encode(), + ) + elif self.path == "/repo.git/git-upload-pack" and self.authorized(): + self.git_backend(body) + else: + self.reply(401) + + def do_GET(self): + if self.path == "/": + self.reply(204) + elif not self.authorized(): + self.reply(401) + elif self.path == "/probe": + self.reply(204) + elif self.path == "/repos/e2e/repo/contents/README.md": + self.reply(200, json.dumps({"name": "README.md", "private": True}).encode()) + elif self.path.startswith("/repo.git/info/refs?"): + self.git_backend(b"") + else: + self.reply(404) + + def git_backend(self, body): + url = urlsplit(self.path) + env = dict( + os.environ, + GIT_PROJECT_ROOT=str(root), + GIT_HTTP_EXPORT_ALL="1", + PATH_INFO=url.path, + QUERY_STRING=url.query, + REQUEST_METHOD=self.command, + CONTENT_TYPE=self.headers.get("Content-Type", ""), + CONTENT_LENGTH=str(len(body)), + ) + # Let the real Git backend negotiate and transfer objects. + response = subprocess.run( + ["git", "http-backend"], + input=body, + env=env, + check=True, + stdout=subprocess.PIPE, + ).stdout + headers, payload = response.split(b"\r\n\r\n", 1) + self.send_response(200) + for line in headers.split(b"\r\n"): + name, value = line.decode().split(":", 1) + self.send_header(name, value.strip()) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, *_args): + pass + + +tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) +tls.load_cert_chain("server.crt", "server.key") + + +class HttpsServer(ThreadingHTTPServer): + def get_request(self): + stream, address = super().get_request() + stream.settimeout(20) + stream.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + # Handshake in the request thread. A preconnected or abandoned socket + # must not block accept() and starve subsequent gh/Git connections. + return tls.wrap_socket( + stream, server_side=True, do_handshake_on_connect=False + ), address + + +https = HttpsServer(("0.0.0.0", 8443), Handler) +threading.Thread(target=https.serve_forever, daemon=True).start() +ThreadingHTTPServer(("0.0.0.0", 8000), Handler).serve_forever() diff --git a/e2e/rust/tests/support/github_app_tls.rs b/e2e/rust/tests/support/github_app_tls.rs new file mode 100644 index 0000000000..24fb6fde89 --- /dev/null +++ b/e2e/rust/tests/support/github_app_tls.rs @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Test-only CA trust for the managed gateway's supervisor image. + +use openshell_e2e::harness::{ + cli::wait_for_healthy, container::ImageGuard, gateway::ManagedGateway, +}; +use std::path::PathBuf; +use std::time::Duration; + +pub struct SupervisorTrust { + config_path: PathBuf, + original: String, + // Drop the image only after restoring the gateway configuration. + _image: ImageGuard, +} + +impl SupervisorTrust { + pub async fn install(ca_pem: &str) -> Result { + let args_path = std::env::var("OPENSHELL_E2E_GATEWAY_ARGS_FILE") + .map_err(|_| "GitHub HTTPS fixture requires a harness-managed gateway".to_string())?; + let raw = std::fs::read(&args_path).map_err(|e| e.to_string())?; + let args: Vec<_> = raw.split(|b| *b == 0).collect(); + let config_path = args + .windows(2) + .find(|pair| pair[0] == b"--config") + .map(|pair| PathBuf::from(String::from_utf8_lossy(pair[1]).into_owned())) + .ok_or_else(|| "managed gateway has no --config".to_string())?; + let original = std::fs::read_to_string(&config_path).map_err(|e| e.to_string())?; + let line = original + .lines() + .find(|line| line.starts_with("supervisor_image = ")) + .ok_or_else(|| "managed Podman gateway has no supervisor_image".to_string())?; + let base = line + .trim_start_matches("supervisor_image = ") + .trim_matches('"'); + let context = tempfile::tempdir().map_err(|e| e.to_string())?; + std::fs::write(context.path().join("ca.crt"), ca_pem).map_err(|e| e.to_string())?; + let dockerfile = context.path().join("Dockerfile"); + // The test supervisor trusts the fixture CA. TLS and hostname checks + // remain enabled; the workload gets the normal supervisor trust bundle. + std::fs::write( + &dockerfile, + format!("FROM {base}\nCOPY ca.crt /etc/ssl/certs/ca-certificates.crt\n"), + ) + .map_err(|e| e.to_string())?; + let image = ImageGuard::build("github-app-trust", &dockerfile, context.path())?; + let updated = original.replace(line, &format!("supervisor_image = \"{}\"", image.tag())); + let guard = Self { + config_path, + original, + _image: image, + }; + std::fs::write(&guard.config_path, updated).map_err(|e| e.to_string())?; + restart().await?; + Ok(guard) + } + + pub async fn restore(self) -> Result<(), String> { + std::fs::write(&self.config_path, &self.original).map_err(|e| e.to_string())?; + restart().await?; + Ok(()) + } +} + +impl Drop for SupervisorTrust { + fn drop(&mut self) { + // Also restore on early returns or panics, before the image guard drops. + if std::fs::read_to_string(&self.config_path).ok().as_ref() != Some(&self.original) { + let _ = std::fs::write(&self.config_path, &self.original); + if let Ok(Some(gateway)) = ManagedGateway::from_env() { + let _ = gateway.stop(); + let _ = gateway.start(); + } + } + } +} + +async fn restart() -> Result<(), String> { + let gateway = ManagedGateway::from_env()? + .ok_or_else(|| "GitHub HTTPS fixture requires a harness-managed gateway".to_string())?; + gateway.stop()?; + gateway.start()?; + wait_for_healthy(Duration::from_secs(120)).await +} diff --git a/e2e/rust/tests/support/oauth_refresh_fixture.py b/e2e/rust/tests/support/oauth_refresh_fixture.py new file mode 100644 index 0000000000..f0370575b9 --- /dev/null +++ b/e2e/rust/tests/support/oauth_refresh_fixture.py @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""HTTP OAuth issuer and resource for managed or external gateway tests.""" + +import base64 +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +current_token = "bootstrap-token" +generation = 0 +lock = threading.Lock() + + +class Handler(BaseHTTPRequestHandler): + def reply(self, status, body=b""): + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self): + global current_token, generation + self.rfile.read(int(self.headers.get("Content-Length", "0"))) + if self.path != "/token": + self.reply(404) + return + with lock: + generation += 1 + current_token = f"oauth-token-{generation}" + token = current_token + self.reply( + 200, + json.dumps( + {"access_token": token, "expires_in": 300, "token_type": "Bearer"} + ).encode(), + ) + + def do_GET(self): + with lock: + token = current_token + basic = "Basic " + base64.b64encode(f"x-access-token:{token}".encode()).decode() + authorized = self.headers.get("Authorization") in (f"Bearer {token}", basic) + if self.path == "/" or (self.path == "/probe" and authorized): + self.reply(204) + else: + self.reply(401) + + def log_message(self, *_args): + pass + + +ThreadingHTTPServer(("0.0.0.0", 8000), Handler).serve_forever() diff --git a/e2e/rust/tests/support/provider_refresh_handles.rs b/e2e/rust/tests/support/provider_refresh_handles.rs new file mode 100644 index 0000000000..3f5f841465 --- /dev/null +++ b/e2e/rust/tests/support/provider_refresh_handles.rs @@ -0,0 +1,489 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "e2e-podman")] + +//! Podman E2E coverage for refresh-managed workload credential handles. +//! +//! A fake issuer invalidates every prior access token. One long-running shell +//! retains its original environment while the gateway rotates the provider 12 +//! times. The shell must continue reaching the resource with the newest token, +//! and explicit refresh reconfiguration must revoke its old handle. + +use std::io::Write; +use std::process::Stdio; +use std::time::Duration; + +use openshell_e2e::harness::binary::openshell_cmd; +use openshell_e2e::harness::container::{HostSupportContainer, ImageGuard}; +use openshell_e2e::harness::port::find_free_port; +use openshell_e2e::harness::sandbox::{E2E_WORKLOAD_IMAGE, SandboxGuard}; +use tempfile::{Builder as TempFileBuilder, NamedTempFile}; + +use super::{PROFILE_ID, PROVIDER_NAME}; + +#[path = "github_app_tls.rs"] +mod github_app_tls; + +const TOKEN_ENV: &str = "REFRESH_E2E_ACCESS_TOKEN"; +const READY_MARKER: &str = "stable-refresh-parent-ready"; + +async fn run_cli(args: &[&str]) -> Result { + run_cli_with_env(args, &[]).await +} + +async fn run_cli_with_env(args: &[&str], env: &[(&str, &str)]) -> Result { + let mut command = openshell_cmd(); + command + .args(args) + .envs(env.iter().copied()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let output = command + .output() + .await + .map_err(|error| format!("run openshell command: {error}"))?; + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + if !output.status.success() { + return Err(format!( + "openshell command failed (exit {:?}):\n{combined}", + output.status.code() + )); + } + Ok(combined) +} + +async fn delete_provider_resources() { + let _ = run_cli(&["provider", "delete", PROVIDER_NAME]).await; + let _ = run_cli(&["profile", "delete", PROFILE_ID]).await; +} + +fn write_profile( + resource_host: &str, + resource_port: u16, + token_port: u16, + github: bool, +) -> Result { + let mut file = TempFileBuilder::new() + .suffix(".yaml") + .tempfile() + .map_err(|error| format!("create profile: {error}"))?; + let profile = format!( + r"id: {PROFILE_ID} +display_name: Stable refresh handle E2E +category: other +credentials: + - name: access_token + env_vars: [{TOKEN_ENV}] + required: true + auth_style: bearer + header_name: authorization + refresh: + strategy: oauth2_client_credentials + token_url: http://127.0.0.1:{token_port}/token + refresh_before_seconds: 30 + max_lifetime_seconds: 300 + material: + - name: client_id + required: true + - name: client_secret + required: true + secret: true +endpoints: + - host: {resource_host} + port: {resource_port} + path: /probe + protocol: rest + access: full + enforcement: enforce + allowed_ips: + - {resource_host}/32 +binaries: + - /usr/bin/python3.12 +" + ); + let profile = if github { + profile + .replace("oauth2_client_credentials", "github_app_installation") + .replace( + "/token\n", + "/app/installations/{installation_id}/access_tokens\n", + ) + .replace("name: client_secret", "name: private_key") + .replace("path: /probe", "path: /**") + .replace( + " - /usr/bin/python3.12", + " - /usr/bin/gh\n - /usr/bin/git", + ) + } else { + profile + }; + file.write_all(profile.as_bytes()) + .map_err(|error| format!("write profile: {error}"))?; + file.flush() + .map_err(|error| format!("flush profile: {error}"))?; + Ok(file) +} + +fn write_policy( + resource_host: &str, + resource_port: u16, + github: bool, +) -> Result { + let mut file = TempFileBuilder::new() + .suffix(".yaml") + .tempfile() + .map_err(|error| format!("create policy: {error}"))?; + let policy = format!( + r"version: 1 +filesystem_policy: + include_workdir: true + read_only: [/usr, /lib, /proc, /etc, /dev/urandom] + read_write: [/sandbox, /tmp, /dev/null] +landlock: + compatibility: best_effort +process: + run_as_user: '1000' + run_as_group: '1000' +network_policies: + refresh_probe: + name: refresh_probe + endpoints: + - host: {resource_host} + port: {resource_port} + path: /probe + protocol: rest + access: full + enforcement: enforce + allowed_ips: + - {resource_host}/32 + binaries: + - path: /usr/bin/python3.12 +" + ); + let policy = if github { + policy.replace("path: /probe", "path: /**").replace( + " - path: /usr/bin/python3.12", + " - path: /usr/bin/gh\n - path: /usr/bin/git", + ) + } else { + policy + }; + file.write_all(policy.as_bytes()) + .map_err(|error| format!("write policy: {error}"))?; + file.flush() + .map_err(|error| format!("flush policy: {error}"))?; + Ok(file) +} + +async fn configure_refresh( + profile: &NamedTempFile, + github_key: Option<&str>, +) -> Result<(), String> { + let profile_path = profile.path().to_string_lossy().into_owned(); + run_cli(&["profile", "import", "--file", &profile_path]).await?; + run_cli_with_env( + &[ + "provider", + "create", + "--name", + PROVIDER_NAME, + "--type", + PROFILE_ID, + "--runtime-credentials", + ], + &[(TOKEN_ENV, "bootstrap-token")], + ) + .await?; + reconfigure_refresh(github_key).await?; + rotate().await +} + +async fn reconfigure_refresh(github_key: Option<&str>) -> Result<(), String> { + if let Some(key) = github_key { + return run_cli_with_env( + &[ + "provider", + "refresh", + "configure", + PROVIDER_NAME, + "--credential-key", + TOKEN_ENV, + "--strategy", + "github-app-installation", + "--material", + "client_id=e2e-client", + "--material", + "installation_id=123", + "--material", + "repository_ids=[42]", + "--material", + "permissions={\"contents\":\"read\"}", + "--secret-material-env", + "private_key=REFRESH_E2E_PRIVATE_KEY", + ], + &[("REFRESH_E2E_PRIVATE_KEY", key)], + ) + .await + .map(|_| ()); + } + run_cli_with_env( + &[ + "provider", + "refresh", + "configure", + PROVIDER_NAME, + "--credential-key", + TOKEN_ENV, + "--strategy", + "oauth2-client-credentials", + "--material", + "client_id=e2e-client", + "--secret-material-env", + "client_secret=REFRESH_E2E_CLIENT_SECRET", + ], + &[("REFRESH_E2E_CLIENT_SECRET", "e2e-client-secret")], + ) + .await + .map(|_| ()) +} + +async fn rotate() -> Result<(), String> { + run_cli(&[ + "provider", + "refresh", + "rotate", + PROVIDER_NAME, + "--credential-key", + TOKEN_ENV, + ]) + .await + .map(|_| ()) +} + +async fn trigger_probe(sandbox: &SandboxGuard) -> Result { + // Poll inside one exec rather than opening an SSH relay for every poll. + let output = tokio::time::timeout(Duration::from_secs(60), sandbox.exec(&[ + "sh", "-c", + "rm -f /sandbox/probe-result; touch /sandbox/probe-trigger; for i in $(seq 1 180); do if [ -f /sandbox/probe-result ]; then cat /sandbox/probe-result; exit 0; fi; sleep 0.25; done; echo 'timed out waiting for credential probe' >&2; exit 1", + ])).await.map_err(|_| "credential probe exec timed out".to_string())??; + Ok(output.trim().to_string()) +} + +async fn wait_for_probe_success(sandbox: &SandboxGuard) -> Result<(), String> { + let deadline = tokio::time::Instant::now() + Duration::from_secs(45); + loop { + if trigger_probe(sandbox).await? == "ok" { + return Ok(()); + } + if tokio::time::Instant::now() >= deadline { + return Err("long-running process never resolved the latest rotated token".to_string()); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } +} + +async fn wait_for_probe_failure(sandbox: &SandboxGuard) -> Result<(), String> { + let deadline = tokio::time::Instant::now() + Duration::from_secs(45); + loop { + if trigger_probe(sandbox).await? == "failed" { + return Ok(()); + } + if tokio::time::Instant::now() >= deadline { + return Err("old workload handle survived explicit reconfiguration".to_string()); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } +} + +pub async fn exercise_refresh_handles(github_key: Option<&str>) -> Result<(), String> { + delete_provider_resources().await; + // The host-networked supervisor reaches the published fixture directly. + // UDP connect selects an interface without sending packets or requiring DNS. + let route = std::net::UdpSocket::bind("0.0.0.0:0").map_err(|e| e.to_string())?; + route.connect("192.0.2.1:80").map_err(|e| e.to_string())?; + let resource_host = route + .local_addr() + .map_err(|e| e.to_string())? + .ip() + .to_string(); + let tools = if github_key.is_some() { + let context = tempfile::tempdir().map_err(|e| e.to_string())?; + let dockerfile = context.path().join("Dockerfile"); + std::fs::write(&dockerfile, format!( + "FROM {E2E_WORKLOAD_IMAGE}\nUSER root\nRUN apt-get update && apt-get install -y --no-install-recommends gh git openssl ca-certificates && rm -rf /var/lib/apt/lists/*\nUSER sandbox:sandbox\n" + )).map_err(|e| e.to_string())?; + Some(ImageGuard::build( + "github-app-tools", + &dockerfile, + context.path(), + )?) + } else { + None + }; + let resource_port = find_free_port(); + let (fixture, trust) = if let Some(tools) = &tools { + let fixture = HostSupportContainer::start_python_image_with_host_bindings( + tools.tag(), + &include_str!("github_app_fixture.py").replace("FIXTURE_HOST", &resource_host), + &[(find_free_port(), 8000), (resource_port, 8443)], + 8000, + &[], + ) + .await?; + let logs = fixture.logs()?; + let start = logs + .find("-----BEGIN CERTIFICATE-----") + .ok_or("fixture did not emit its CA")?; + let end = logs[start..] + .find("-----END CERTIFICATE-----") + .ok_or("incomplete fixture CA")? + + start + + "-----END CERTIFICATE-----".len(); + let trust = github_app_tls::SupervisorTrust::install(&logs[start..end]).await?; + (fixture, Some(trust)) + } else { + // OAuth also runs against an existing gateway, whose supervisor image + // and lifecycle are outside the test's control. Keep its fixture HTTP. + let fixture = HostSupportContainer::start_python_on_host_port( + include_str!("oauth_refresh_fixture.py"), + 8000, + resource_port, + ) + .await?; + (fixture, None) + }; + let profile = write_profile( + &resource_host, + resource_port, + fixture.port, + github_key.is_some(), + )?; + let policy = write_policy(&resource_host, resource_port, github_key.is_some())?; + configure_refresh(&profile, github_key).await?; + + let policy_path = policy.path().to_string_lossy().into_owned(); + let probe = if github_key.is_some() { + format!( + r#"export GH_ENTERPRISE_TOKEN="$REFRESH_E2E_ACCESS_TOKEN" +api_ok=0 +git_ok=0 +if [ "$(timeout 15s gh api -H "Authorization: Bearer $REFRESH_E2E_ACCESS_TOKEN" https://{resource_host}:{resource_port}/repos/e2e/repo/contents/README.md --jq .name)" = README.md ]; then api_ok=1; fi +auth="Authorization: Basic $(printf 'x-access-token:%s' "$REFRESH_E2E_ACCESS_TOKEN" | base64 -w0)" +if [ -d /sandbox/repo/.git ]; then + timeout 15s git -c protocol.version=0 -c http.extraHeader="$auth" -C /sandbox/repo fetch --quiet origin && git_ok=1 +else + timeout 15s git -c protocol.version=0 -c http.extraHeader="$auth" clone --quiet https://{resource_host}:{resource_port}/repo.git /sandbox/repo && git_ok=1 +fi +if [ "$api_ok:$git_ok" = 1:1 ] && [ "$(cat /sandbox/repo/README.md)" = 'GitHub App installation token E2E' ]; then + echo ok +elif [ "$api_ok:$git_ok" = 0:0 ]; then + echo failed +else + echo mixed-result +fi"# + ) + } else { + format!( + r#"if /usr/bin/python3.12 -c 'import os, base64, urllib.request; token = os.environ["REFRESH_E2E_ACCESS_TOKEN"]; auths = ["Bearer " + token, "Basic " + base64.b64encode(("x-access-token:" + token).encode()).decode()]; [urllib.request.urlopen(urllib.request.Request("http://{resource_host}:{resource_port}/probe", headers=dict(Authorization=auth)), timeout=5).read() for auth in auths]'; then echo ok; else echo failed; fi"# + ) + }; + let parent_script = format!( + r#"case "$REFRESH_E2E_ACCESS_TOKEN" in + openshell:resolve:env:s*_REFRESH_E2E_ACCESS_TOKEN) ;; + *) exit 64 ;; +esac +test -z "${{REFRESH_E2E_PRIVATE_KEY+x}}" || exit 65 +echo {READY_MARKER} +while true; do + if [ -f /sandbox/probe-trigger ]; then + rm -f /sandbox/probe-trigger + ( +{probe} + ) > /sandbox/probe-result.tmp 2> /sandbox/probe-error + mv /sandbox/probe-result.tmp /sandbox/probe-result + fi + sleep 0.1 +done"# + ); + let create_args = [ + "--provider", + PROVIDER_NAME, + "--policy", + &policy_path, + "--from", + tools.as_ref().map_or(E2E_WORKLOAD_IMAGE, ImageGuard::tag), + ]; + let mut sandbox = SandboxGuard::create_keep_with_args( + &create_args, + &["sh", "-c", &parent_script], + READY_MARKER, + ) + .await?; + + let result = async { + let initial = trigger_probe(&sandbox).await?; + if initial != "ok" { + return Err(format!( + "initial long-running credential probe failed: {initial}" + )); + } + + for _ in 0..12 { + rotate().await?; + wait_for_probe_success(&sandbox).await?; + } + + reconfigure_refresh(github_key).await?; + wait_for_probe_failure(&sandbox).await?; + + rotate().await?; + let deadline = tokio::time::Instant::now() + Duration::from_secs(45); + loop { + // Each exec gets a fresh environment after configuration sync. + let fresh = sandbox.exec(&["sh", "-c", &probe]).await?; + if fresh.trim() == "ok" { + break; + } + if tokio::time::Instant::now() >= deadline { + return Err(format!( + "fresh workload failed after reconfiguration: {fresh}" + )); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + if trigger_probe(&sandbox).await? != "failed" { + return Err("old workload handle revived after replacement token mint".into()); + } + Ok(()) + } + .await; + + if result.is_err() { + eprintln!( + "probe diagnostics: {:?}", + sandbox.exec(&["cat", "/sandbox/probe-error"]).await + ); + eprintln!( + "sandbox diagnostics: {:?}", + run_cli(&["logs", &sandbox.name, "-n", "100"]).await + ); + eprintln!("hosts: {:?}", sandbox.exec(&["cat", "/etc/hosts"]).await); + } + sandbox.cleanup().await; + delete_provider_resources().await; + if result.is_err() { + eprintln!( + "fixture diagnostics: {}", + fixture.logs().unwrap_or_default() + ); + } + if let Some(trust) = trust { + trust.restore().await?; + } + result +} diff --git a/proto/openshell.proto b/proto/openshell.proto index a836e169bf..7a3922dcce 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -2308,6 +2308,8 @@ enum ProviderCredentialRefreshStrategy { PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS = 4; PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT = 5; PROVIDER_CREDENTIAL_REFRESH_STRATEGY_AWS_STS_ASSUME_ROLE = 6; + // Gateway signs an app JWT and mints a scoped installation access token. + PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GITHUB_APP_INSTALLATION = 7; } message ProviderCredentialRefreshMaterial { diff --git a/providers/README.md b/providers/README.md index c7a5762643..88f95dc993 100644 --- a/providers/README.md +++ b/providers/README.md @@ -16,6 +16,11 @@ openshell provider profile lint -f providers/github.yaml openshell provider profile import -f providers/github.yaml --global ``` +For gateway-minted GitHub App installation tokens, start with `github-app.yaml`. +Configure its `github-app-installation` refresh with explicit repository IDs and +permissions, and supply the private key through secret refresh material. See the +[provider documentation](https://docs.nvidia.com/openshell/latest/providers/profiles.md). + Or import the whole directory: ```shell diff --git a/providers/github-app.yaml b/providers/github-app.yaml new file mode 100644 index 0000000000..89be29b55d --- /dev/null +++ b/providers/github-app.yaml @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Import this example explicitly after adapting binaries to your sandbox image. +# Configure github-app-installation refresh with client_id, installation_id, +# private_key (secret), repository_ids (JSON array), and permissions (JSON object). +# Choose GITHUB_TOKEN or GH_TOKEN as the refresh credential key; only the chosen +# key is injected. api_token selects the configured alias, defaulting to +# GITHUB_TOKEN. Only one alias can have refresh configured at a time. +# The app key and JWT remain at the gateway. +id: github-app +display_name: GitHub App +description: GitHub App installation tokens with gateway-managed rotation +category: source_control +credentials: + - name: api_token + description: Short-lived GitHub installation token + env_vars: [GITHUB_TOKEN, GH_TOKEN] + required: true + auth_style: bearer + header_name: authorization + refresh: + strategy: github_app_installation + token_url: https://api.github.com/app/installations/{installation_id}/access_tokens + refresh_before: 300s + max_lifetime: 3600s + material: + - name: client_id + description: GitHub App client ID used as the JWT issuer + required: true + - name: installation_id + description: Positive installation ID for the app + required: true + - name: private_key + description: GitHub App RSA PEM private key + required: true + secret: true + - name: repository_ids + description: JSON array of 1 to 500 distinct positive repository IDs + required: true + - name: permissions + description: JSON object of explicit GitHub permission names and levels + required: true +# Token permission does not override sandbox policy. Writes and push stay denied. +endpoints: + - host: api.github.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce + - host: api.github.com + port: 443 + path: /graphql + protocol: graphql + access: read-only + enforcement: enforce + - host: github.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "**" } + - allow: { method: HEAD, path: "**" } + - allow: { method: OPTIONS, path: "**" } + - allow: { method: POST, path: "/**/git-upload-pack" } +binaries: [/usr/bin/gh, /usr/local/bin/gh, /usr/bin/git, /usr/local/bin/git] diff --git a/sdk/go/openshell/v1/example_test.go b/sdk/go/openshell/v1/example_test.go index c69e7816e1..ea5fddb05d 100644 --- a/sdk/go/openshell/v1/example_test.go +++ b/sdk/go/openshell/v1/example_test.go @@ -12,6 +12,16 @@ import ( "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/fake" ) +func ExampleRefreshStrategyGitHubAppInstallation() { + config := v1.RefreshConfig{ + Provider: "github-app-test", + CredentialKey: "GITHUB_TOKEN", + Strategy: v1.RefreshStrategyGitHubAppInstallation, + } + fmt.Println(config.Strategy) + // Output: GitHubAppInstallation +} + // ExampleClient_Sandboxes demonstrates the sandbox lifecycle: create a sandbox, // wait for it to become ready, and then clean up. func ExampleClient_Sandboxes() { diff --git a/sdk/go/openshell/v1/internal/converter/refresh.go b/sdk/go/openshell/v1/internal/converter/refresh.go index b638050347..663058f79d 100644 --- a/sdk/go/openshell/v1/internal/converter/refresh.go +++ b/sdk/go/openshell/v1/internal/converter/refresh.go @@ -25,6 +25,8 @@ func RefreshStrategyFromProto(s pb.ProviderCredentialRefreshStrategy) types.Refr return types.RefreshStrategyGoogleServiceAccountJWT case pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_AWS_STS_ASSUME_ROLE: return types.RefreshStrategyAWSStsAssumeRole + case pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GITHUB_APP_INSTALLATION: + return types.RefreshStrategyGitHubAppInstallation default: return types.RefreshStrategy("") } @@ -63,6 +65,8 @@ func RefreshStrategyToProto(s types.RefreshStrategy) pb.ProviderCredentialRefres return pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT case types.RefreshStrategyAWSStsAssumeRole: return pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_AWS_STS_ASSUME_ROLE + case types.RefreshStrategyGitHubAppInstallation: + return pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GITHUB_APP_INSTALLATION default: return pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED } diff --git a/sdk/go/openshell/v1/internal/converter/refresh_test.go b/sdk/go/openshell/v1/internal/converter/refresh_test.go index 9d94889a38..0e2a1bab80 100644 --- a/sdk/go/openshell/v1/internal/converter/refresh_test.go +++ b/sdk/go/openshell/v1/internal/converter/refresh_test.go @@ -27,6 +27,7 @@ func TestRefreshStrategyFromProto(t *testing.T) { {pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS, v1.RefreshStrategyOAuth2ClientCredentials}, {pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT, v1.RefreshStrategyGoogleServiceAccountJWT}, {pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_AWS_STS_ASSUME_ROLE, v1.RefreshStrategyAWSStsAssumeRole}, + {pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GITHUB_APP_INSTALLATION, v1.RefreshStrategyGitHubAppInstallation}, {pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED, v1.RefreshStrategy("")}, } for _, tt := range tests { @@ -64,6 +65,7 @@ func TestRefreshStrategyToProto(t *testing.T) { {v1.RefreshStrategyOAuth2ClientCredentials, pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS}, {v1.RefreshStrategyGoogleServiceAccountJWT, pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT}, {v1.RefreshStrategyAWSStsAssumeRole, pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_AWS_STS_ASSUME_ROLE}, + {v1.RefreshStrategyGitHubAppInstallation, pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GITHUB_APP_INSTALLATION}, {v1.RefreshStrategy(""), pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED}, {v1.RefreshStrategy("Unknown"), pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED}, } diff --git a/sdk/go/openshell/v1/refresh.go b/sdk/go/openshell/v1/refresh.go index c676697fb4..185df8d721 100644 --- a/sdk/go/openshell/v1/refresh.go +++ b/sdk/go/openshell/v1/refresh.go @@ -25,6 +25,7 @@ const ( RefreshStrategyOAuth2RefreshToken = types.RefreshStrategyOAuth2RefreshToken RefreshStrategyOAuth2ClientCredentials = types.RefreshStrategyOAuth2ClientCredentials RefreshStrategyGoogleServiceAccountJWT = types.RefreshStrategyGoogleServiceAccountJWT + RefreshStrategyGitHubAppInstallation = types.RefreshStrategyGitHubAppInstallation ) // RefreshInterface defines operations for managing provider credential refresh. diff --git a/sdk/go/openshell/v1/types/refresh.go b/sdk/go/openshell/v1/types/refresh.go index 33cf56060b..2e1bc9426f 100644 --- a/sdk/go/openshell/v1/types/refresh.go +++ b/sdk/go/openshell/v1/types/refresh.go @@ -16,6 +16,7 @@ const ( RefreshStrategyOAuth2ClientCredentials RefreshStrategy = "OAuth2ClientCredentials" RefreshStrategyGoogleServiceAccountJWT RefreshStrategy = "GoogleServiceAccountJWT" RefreshStrategyAWSStsAssumeRole RefreshStrategy = "AWSStsAssumeRole" + RefreshStrategyGitHubAppInstallation RefreshStrategy = "GitHubAppInstallation" ) // RefreshRecoveryAction describes the action required after a refresh failure. diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index 18d0b9bcde..8a0416c2ac 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -613,6 +613,8 @@ const ( ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS ProviderCredentialRefreshStrategy = 4 ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT ProviderCredentialRefreshStrategy = 5 ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_AWS_STS_ASSUME_ROLE ProviderCredentialRefreshStrategy = 6 + // Gateway signs an app JWT and mints a scoped installation access token. + ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GITHUB_APP_INSTALLATION ProviderCredentialRefreshStrategy = 7 ) // Enum value maps for ProviderCredentialRefreshStrategy. @@ -625,6 +627,7 @@ var ( 4: "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS", 5: "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT", 6: "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_AWS_STS_ASSUME_ROLE", + 7: "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GITHUB_APP_INSTALLATION", } ProviderCredentialRefreshStrategy_value = map[string]int32{ "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED": 0, @@ -634,6 +637,7 @@ var ( "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS": 4, "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT": 5, "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_AWS_STS_ASSUME_ROLE": 6, + "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GITHUB_APP_INSTALLATION": 7, } ) @@ -18964,7 +18968,7 @@ const file_openshell_proto_rawDesc = "" + " ProviderCredentialTokenGrantType\x124\n" + "0PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_UNSPECIFIED\x10\x00\x12;\n" + "7PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_CLIENT_CREDENTIALS\x10\x01\x127\n" + - "3PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_TOKEN_EXCHANGE\x10\x02*\xc3\x03\n" + + "3PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_TOKEN_EXCHANGE\x10\x02*\x85\x04\n" + "!ProviderCredentialRefreshStrategy\x124\n" + "0PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED\x10\x00\x12/\n" + "+PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC\x10\x01\x121\n" + @@ -18972,7 +18976,8 @@ const file_openshell_proto_rawDesc = "" + "9PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN\x10\x03\x12B\n" + ">PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS\x10\x04\x12C\n" + "?PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT\x10\x05\x12<\n" + - "8PROVIDER_CREDENTIAL_REFRESH_STRATEGY_AWS_STS_ASSUME_ROLE\x10\x06*\xdb\x02\n" + + "8PROVIDER_CREDENTIAL_REFRESH_STRATEGY_AWS_STS_ASSUME_ROLE\x10\x06\x12@\n" + + "