diff --git a/Cargo.lock b/Cargo.lock index d8b2f36221..6c4fea0c15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5314,6 +5314,7 @@ dependencies = [ "aws-credential-types", "aws-sigv4", "aws-smithy-runtime-api", + "axum", "base64 0.22.1", "bytes", "flate2", @@ -5323,6 +5324,7 @@ dependencies = [ "hickory-proto", "http 1.4.0", "ipnet", + "jsonwebtoken", "libc", "miette", "nix 0.29.0", @@ -5339,6 +5341,7 @@ dependencies = [ "openshell-policy", "openshell-policy-schema", "openshell-sandbox-backend", + "openshell-server", "openshell-supervisor-middleware", "openshell-supervisor-middleware-builtins", "opentelemetry-proto", diff --git a/crates/openshell-cli/src/commands/provider.rs b/crates/openshell-cli/src/commands/provider.rs index efc3d1bb12..f67ec5ab77 100644 --- a/crates/openshell-cli/src/commands/provider.rs +++ b/crates/openshell-cli/src/commands/provider.rs @@ -822,6 +822,9 @@ pub async fn fetch_provider_profile_catalog( .await .into_diagnostic()? .into_inner(); + for profile in &response.profiles { + validate_profile_grant_types(profile).into_diagnostic()?; + } profiles.extend(response.profiles); if response.next_page_token.is_empty() { break; @@ -869,13 +872,25 @@ async fn fetch_provider_profile_exact( }) .await .and_then(|response| { - response - .into_inner() - .profile - .ok_or_else(|| Status::internal("provider profile missing from response")) + let profile = response.into_inner().profile.ok_or_else(|| { + Status::internal(format!( + "provider profile '{provider_type}' missing from response" + )) + })?; + validate_profile_grant_types(&profile)?; + Ok(profile) }) } +fn validate_profile_grant_types(profile: &ProviderProfile) -> std::result::Result<(), Status> { + if let Some(diagnostic) = + openshell_providers::unknown_token_grant_type_diagnostic("gateway", profile) + { + return Err(Status::failed_precondition(diagnostic.message)); + } + Ok(()) +} + async fn discover_existing_provider_data( client: &mut crate::tls::GrpcClient, provider_type: &str, @@ -1679,17 +1694,9 @@ pub async fn provider_profile_describe_text( tls: &TlsOptions, ) -> Result { let mut client = grpc_client(server, tls).await?; - let response = client - .get_provider_profile(GetProviderProfileRequest { - id: id.to_string(), - workspace_scope: provider_profile_workspace_scope(workspace), - }) + let profile = fetch_provider_profile_exact(&mut client, id, workspace) .await .into_diagnostic()?; - let profile = response - .into_inner() - .profile - .ok_or_else(|| miette!("provider profile '{id}' not found"))?; format_provider_profile_description(&profile, output) } @@ -1718,17 +1725,9 @@ pub async fn provider_profile_export_text( tls: &TlsOptions, ) -> Result { let mut client = grpc_client(server, tls).await?; - let response = client - .get_provider_profile(GetProviderProfileRequest { - id: id.to_string(), - workspace_scope: provider_profile_workspace_scope(workspace), - }) + let profile = fetch_provider_profile_exact(&mut client, id, workspace) .await .into_diagnostic()?; - let profile = response - .into_inner() - .profile - .ok_or_else(|| miette!("provider profile '{id}' not found"))?; let profile = ProviderTypeProfile::from_proto(&profile); match output { @@ -1760,9 +1759,7 @@ pub async fn provider_profile_import( let mut client = grpc_client(server, tls).await?; if !items.is_empty() { - if profiles_require_stable_placeholders(&items) { - lint_profile_items(&mut client, items.clone(), workspace).await?; - } + check_profile_write_capabilities(&mut client, &items, workspace).await?; let response = client .import_provider_profiles(ImportProviderProfilesRequest { request_id: String::new(), @@ -1809,9 +1806,8 @@ pub async fn provider_profile_update( let mut client = grpc_client(server, tls).await?; if let Some(item) = items.pop() { - if profiles_require_stable_placeholders(std::slice::from_ref(&item)) { - lint_profile_items(&mut client, vec![item.clone()], workspace).await?; - } + check_profile_write_capabilities(&mut client, std::slice::from_ref(&item), workspace) + .await?; let expected_resource_version = item .profile .as_ref() @@ -2174,14 +2170,32 @@ fn profiles_require_stable_placeholders(items: &[ProviderProfileImportItem]) -> }) } -// Check support before persisting an opt-in: older gateways discard unknown -// credential fields and can otherwise report a successful revision-scoped import. +fn profiles_require_gateway_assertion(items: &[ProviderProfileImportItem]) -> bool { + items + .iter() + .filter_map(|item| item.profile.as_ref()) + .any(openshell_providers::profile_uses_gateway_assertion) +} + +// Older gateways can accept a profile write without preserving the opt-in. +async fn check_profile_write_capabilities( + client: &mut crate::tls::GrpcClient, + items: &[ProviderProfileImportItem], + workspace: &str, +) -> Result<()> { + if profiles_require_stable_placeholders(items) || profiles_require_gateway_assertion(items) { + lint_profile_items(client, items.to_vec(), workspace).await?; + } + Ok(()) +} + async fn lint_profile_items( client: &mut crate::tls::GrpcClient, items: Vec, workspace: &str, ) -> Result { let requires_stable = profiles_require_stable_placeholders(&items); + let requires_gateway_assertion = profiles_require_gateway_assertion(&items); let response = client .lint_provider_profiles(LintProviderProfilesRequest { profiles: items, @@ -2195,6 +2209,11 @@ async fn lint_profile_items( "gateway does not support stable_placeholder; upgrade the gateway before enabling it" )); } + if requires_gateway_assertion && !response.supports_openshell_gateway_assertion_source { + return Err(miette!( + "gateway does not support assertion_source openshell_gateway; upgrade the gateway before enabling it" + )); + } Ok(response) } diff --git a/crates/openshell-cli/src/tls.rs b/crates/openshell-cli/src/tls.rs index fd138aae5b..290554efbf 100644 --- a/crates/openshell-cli/src/tls.rs +++ b/crates/openshell-cli/src/tls.rs @@ -24,10 +24,8 @@ pub type GrpcClient = openshell_sdk::raw::GrpcClient< /// Interceptor stack applied to gateway requests from clients built by /// [`grpc_client`]. /// -/// Runs authentication header injection first, then passive W3C trace-context -/// forwarding so a CI pipeline's `TRACEPARENT` extends into the gateway's -/// spans. tonic allows a single interceptor per channel, so the two concerns -/// are composed here. +/// Attaches CLI authentication and provider-profile capabilities and forwards +/// W3C trace context from the environment. #[derive(Clone)] pub struct GatewayInterceptor { auth: openshell_sdk::EdgeAuthInterceptor, @@ -39,7 +37,11 @@ impl tonic::service::Interceptor for GatewayInterceptor { &mut self, request: tonic::Request<()>, ) -> std::result::Result, tonic::Status> { - let request = self.auth.call(request)?; + let mut request = self.auth.call(request)?; + request.metadata_mut().insert( + openshell_providers::GATEWAY_ASSERTION_PROFILE_CAPABILITY, + tonic::metadata::MetadataValue::from_static("1"), + ); self.trace.call(request) } } @@ -342,7 +344,7 @@ pub async fn build_channel(server: &str, tls: &TlsOptions) -> Result { /// The returned client carries a [`GatewayInterceptor`] that injects /// authentication headers (when a token is set) and passively forwards the /// `TRACEPARENT`/`TRACESTATE` trace context from the environment on every -/// request. With no token and no trace context, the interceptor is a no-op. +/// request. It also advertises the profile grant types this CLI can preserve. pub async fn grpc_client(server: &str, tls: &TlsOptions) -> Result { let config = sdk_client_config(server, tls)?; // Keep the SDK's transport state machine out of every command future. diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index be3e4f2e49..fe59722103 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -16,8 +16,7 @@ use openshell_core::proto::{ AttachSandboxProviderRequest, AttachSandboxProviderResponse, CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DetachSandboxProviderRequest, DetachSandboxProviderResponse, - ExchangeProviderSubjectTokenRequest, ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, + DetachSandboxProviderRequest, DetachSandboxProviderResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, GatewayMessage, GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxProviderEnvironmentRequest, @@ -211,7 +210,7 @@ impl OpenShell for TestOpenShell { Ok(Response::new(ListSandboxesResponse::default())) } - unimplemented_sandbox_template_rpcs!(); + unused_gateway_rpcs!(sandbox_templates); async fn list_sandbox_providers( &self, @@ -329,12 +328,7 @@ impl OpenShell for TestOpenShell { Ok(Response::new(RevokeSshSessionResponse::default())) } - async fn exchange_provider_subject_token( - &self, - _request: tonic::Request, - ) -> Result, Status> { - Err(Status::unimplemented("unused")) - } + unused_gateway_rpcs!(provider_token_grants); async fn create_provider( &self, diff --git a/crates/openshell-cli/tests/helpers/mod.rs b/crates/openshell-cli/tests/helpers/mod.rs index be84c9ce6e..b1cac2ef3c 100644 --- a/crates/openshell-cli/tests/helpers/mod.rs +++ b/crates/openshell-cli/tests/helpers/mod.rs @@ -22,94 +22,8 @@ pub fn example_profiles() -> &'static [openshell_providers::ProviderTypeProfile] .as_slice() } -#[macro_export] -macro_rules! unimplemented_sandbox_template_rpcs { - () => { - fn create_sandbox_template<'life0, 'async_trait>( - &'life0 self, - _request: tonic::Request, - ) -> std::pin::Pin< - Box< - dyn std::future::Future< - Output = Result< - tonic::Response, - tonic::Status, - >, - > + Send - + 'async_trait, - >, - > - where - 'life0: 'async_trait, - Self: 'async_trait, - { - Box::pin(async { Err(tonic::Status::unimplemented("unused")) }) - } - - fn get_sandbox_template<'life0, 'async_trait>( - &'life0 self, - _request: tonic::Request, - ) -> std::pin::Pin< - Box< - dyn std::future::Future< - Output = Result< - tonic::Response, - tonic::Status, - >, - > + Send - + 'async_trait, - >, - > - where - 'life0: 'async_trait, - Self: 'async_trait, - { - Box::pin(async { Err(tonic::Status::unimplemented("unused")) }) - } - - fn list_sandbox_templates<'life0, 'async_trait>( - &'life0 self, - _request: tonic::Request, - ) -> std::pin::Pin< - Box< - dyn std::future::Future< - Output = Result< - tonic::Response, - tonic::Status, - >, - > + Send - + 'async_trait, - >, - > - where - 'life0: 'async_trait, - Self: 'async_trait, - { - Box::pin(async { Err(tonic::Status::unimplemented("unused")) }) - } - - fn delete_sandbox_template<'life0, 'async_trait>( - &'life0 self, - _request: tonic::Request, - ) -> std::pin::Pin< - Box< - dyn std::future::Future< - Output = Result< - tonic::Response, - tonic::Status, - >, - > + Send - + 'async_trait, - >, - > - where - 'life0: 'async_trait, - Self: 'async_trait, - { - Box::pin(async { Err(tonic::Status::unimplemented("unused")) }) - } - }; -} +#[path = "../../../../tests/support/unused_gateway_rpcs.rs"] +mod unused_gateway_rpcs; use openshell_crypto::pki::Certificate; use openshell_crypto::pki::KeyPair; diff --git a/crates/openshell-cli/tests/mtls_integration.rs b/crates/openshell-cli/tests/mtls_integration.rs index 1017712035..89438fc2cc 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -12,11 +12,10 @@ use openshell_cli::{ }; use openshell_core::proto::{ CreateProviderRequest, CreateSshSessionRequest, CreateSshSessionResponse, - DeleteProviderRequest, DeleteProviderResponse, ExchangeProviderSubjectTokenRequest, - ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, - GetProviderRequest, HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, - ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, ServiceStatus, - UpdateProviderRequest, + DeleteProviderRequest, DeleteProviderResponse, ExecSandboxEvent, ExecSandboxInput, + ExecSandboxRequest, GetProviderRequest, HealthRequest, HealthResponse, ListProvidersRequest, + ListProvidersResponse, ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, + ServiceStatus, UpdateProviderRequest, open_shell_server::{OpenShell, OpenShellServer}, }; use tokio::net::TcpListener; @@ -169,7 +168,7 @@ impl OpenShell for TestOpenShell { )) } - unimplemented_sandbox_template_rpcs!(); + unused_gateway_rpcs!(sandbox_templates); async fn list_sandbox_providers( &self, @@ -300,12 +299,7 @@ impl OpenShell for TestOpenShell { Ok(Response::new(RevokeSshSessionResponse::default())) } - async fn exchange_provider_subject_token( - &self, - _request: tonic::Request, - ) -> Result, Status> { - Err(Status::unimplemented("unused")) - } + unused_gateway_rpcs!(provider_token_grants); async fn create_provider( &self, diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 89247ba718..f153f4491b 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -13,8 +13,7 @@ use openshell_core::proto::{ CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRefreshRequest, DeleteProviderRefreshResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DetachSandboxProviderRequest, DetachSandboxProviderResponse, - ExchangeProviderSubjectTokenRequest, ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, + DetachSandboxProviderRequest, DetachSandboxProviderResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, GatewayMessage, GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderRefreshStatusRequest, GetProviderRefreshStatusResponse, GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, @@ -80,6 +79,7 @@ struct ProviderState { providers: Arc>>, profiles: Arc>>, supports_stable_placeholder: Arc, + supports_gateway_assertion: Arc, scoped_profiles: Arc>>, refresh_statuses: Arc>>, refresh_requests: Arc>>, @@ -389,7 +389,7 @@ impl OpenShell for TestOpenShell { Ok(Response::new(ListSandboxesResponse::default())) } - unimplemented_sandbox_template_rpcs!(); + unused_gateway_rpcs!(sandbox_templates); async fn list_sandbox_providers( &self, @@ -740,12 +740,7 @@ impl OpenShell for TestOpenShell { Ok(Response::new(RevokeSshSessionResponse::default())) } - async fn exchange_provider_subject_token( - &self, - _request: tonic::Request, - ) -> Result, Status> { - Err(Status::unimplemented("unused")) - } + unused_gateway_rpcs!(provider_token_grants); async fn create_provider( &self, @@ -982,6 +977,22 @@ impl OpenShell for TestOpenShell { &self, request: tonic::Request, ) -> Result, Status> { + if request + .get_ref() + .profiles + .iter() + .filter_map(|item| item.profile.as_ref()) + .any(openshell_providers::profile_uses_gateway_assertion) + && request + .metadata() + .get(openshell_providers::GATEWAY_ASSERTION_PROFILE_CAPABILITY) + .and_then(|value| value.to_str().ok()) + != Some("1") + { + return Err(Status::failed_precondition( + "missing gateway assertion capability", + )); + } let mut profiles = self.state.profiles.lock().await; let imported = request .into_inner() @@ -1083,6 +1094,10 @@ impl OpenShell for TestOpenShell { .state .supports_stable_placeholder .load(Ordering::SeqCst), + supports_openshell_gateway_assertion_source: self + .state + .supports_gateway_assertion + .load(Ordering::SeqCst), }, )) } @@ -4480,6 +4495,107 @@ endpoints: assert!(ts.state.profiles.lock().await["second"].credentials[0].stable_placeholder); } +#[tokio::test] +async fn provider_profile_gateway_assertion_negotiates_support_and_round_trips() { + let ts = run_server().await; + let dir = tempfile::tempdir().expect("profile directory"); + let path = dir.path().join("gateway.yaml"); + std::fs::write( + &path, + "id: gateway-assertion\ndisplay_name: Gateway Assertion\ncredentials:\n - name: access\n token_grant:\n grant_type: client_credentials\n assertion_source: openshell_gateway\n token_endpoint: https://issuer.example/token\n", + ) + .unwrap(); + let error = + run::provider_profile_import(&ts.endpoint, Some(&path), None, None, "default", &ts.tls) + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("does not support assertion_source openshell_gateway") + ); + assert!(ts.state.profiles.lock().await.is_empty()); + + ts.state + .supports_gateway_assertion + .store(true, Ordering::SeqCst); + run::provider_profile_import(&ts.endpoint, Some(&path), None, None, "default", &ts.tls) + .await + .unwrap(); + let exported = run::provider_profile_export_text( + &ts.endpoint, + "gateway-assertion", + "yaml", + "default", + &ts.tls, + ) + .await + .unwrap(); + assert!(!exported.contains("grant_type")); + assert!(exported.contains("assertion_source: openshell_gateway")); + let profile = openshell_providers::parse_profile_yaml(&exported) + .unwrap() + .to_proto(); + assert_eq!( + profile.credentials[0] + .token_grant + .as_ref() + .unwrap() + .grant_type, + 3 + ); +} + +#[tokio::test] +async fn unknown_gateway_grant_cannot_be_exported_or_used_to_create_a_provider() { + let ts = run_server().await; + let mut profile = openshell_providers::parse_profile_yaml( + "id: future-grant\ndisplay_name: Future grant\ncredentials:\n - name: access\n token_grant:\n token_endpoint: https://issuer.example/token\n", + ).unwrap().to_proto(); + profile.credentials[0] + .token_grant + .as_mut() + .unwrap() + .grant_type = 77; + ts.state + .profiles + .lock() + .await + .insert(profile.id.clone(), profile); + + let error = + run::provider_profile_export_text(&ts.endpoint, "future-grant", "yaml", "default", &ts.tls) + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("unknown provider token grant type") + ); + let error = run::provider_list_profiles_text(&ts.endpoint, "json", "default", &ts.tls) + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("unknown provider token grant type") + ); + run::provider_create( + &ts.endpoint, + "future-instance", + "future-grant", + false, + &[], + false, + &[], + "default", + &ts.tls, + ) + .await + .unwrap_err(); + assert!(ts.state.providers.lock().await.is_empty()); +} + #[tokio::test] async fn provider_profile_cli_run_functions_support_custom_profiles() { let ts = run_server().await; diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 8e7c0b190f..f5b114c7a5 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -16,8 +16,7 @@ use openshell_core::proto::{ CreateSandboxRequest, CreateSandboxTemplateRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, DeleteSandboxTemplateRequest, DetachSandboxProviderRequest, - DetachSandboxProviderResponse, ExchangeProviderSubjectTokenRequest, - ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, + DetachSandboxProviderResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, GatewayMessage, GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxTemplateRequest, @@ -565,12 +564,7 @@ impl OpenShell for TestOpenShell { Ok(Response::new(RevokeSshSessionResponse::default())) } - async fn exchange_provider_subject_token( - &self, - _request: tonic::Request, - ) -> Result, Status> { - Err(Status::unimplemented("unused")) - } + unused_gateway_rpcs!(provider_token_grants); async fn create_provider( &self, diff --git a/crates/openshell-cli/tests/sandbox_exec_streaming_integration.rs b/crates/openshell-cli/tests/sandbox_exec_streaming_integration.rs index 31e67baf0f..c60210497c 100644 --- a/crates/openshell-cli/tests/sandbox_exec_streaming_integration.rs +++ b/crates/openshell-cli/tests/sandbox_exec_streaming_integration.rs @@ -270,6 +270,7 @@ mock_gateway! { report_provider_readiness(proto::ReportProviderReadinessRequest) -> proto::ReportProviderReadinessResponse; report_sandbox_configuration(proto::ReportSandboxConfigurationRequest) -> proto::ReportSandboxConfigurationResponse; get_sandbox_provider_environment(proto::GetSandboxProviderEnvironmentRequest) -> proto::GetSandboxProviderEnvironmentResponse; + mint_provider_client_assertion(proto::MintProviderClientAssertionRequest) -> proto::MintProviderClientAssertionResponse; exchange_provider_subject_token(proto::ExchangeProviderSubjectTokenRequest) -> proto::ExchangeProviderSubjectTokenResponse; get_sandbox_logs(proto::GetSandboxLogsRequest) -> proto::GetSandboxLogsResponse; report_main_process_exit(proto::ReportMainProcessExitRequest) -> proto::ReportMainProcessExitResponse; diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index 497f3ce778..919ea23134 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -13,8 +13,7 @@ use openshell_core::proto::{ AttachSandboxProviderRequest, AttachSandboxProviderResponse, CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DetachSandboxProviderRequest, DetachSandboxProviderResponse, - ExchangeProviderSubjectTokenRequest, ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, + DetachSandboxProviderRequest, DetachSandboxProviderResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, GatewayMessage, GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse, @@ -196,7 +195,7 @@ impl OpenShell for TestOpenShell { Ok(Response::new(ListSandboxesResponse::default())) } - unimplemented_sandbox_template_rpcs!(); + unused_gateway_rpcs!(sandbox_templates); async fn list_sandbox_providers( &self, @@ -364,12 +363,7 @@ impl OpenShell for TestOpenShell { )) } - async fn exchange_provider_subject_token( - &self, - _request: tonic::Request, - ) -> Result, Status> { - Err(Status::unimplemented("unused")) - } + unused_gateway_rpcs!(provider_token_grants); async fn create_provider( &self, diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index 64268a7866..53c0a2baff 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -1647,6 +1647,61 @@ mod provider_environment_tests { } } +/// Obtain a dedicated provider assertion over the supervisor's authenticated +/// gateway channel. This method never sends a session bearer to a provider. +pub async fn mint_provider_client_assertion( + gateway: &GatewayEndpoint, + body: crate::proto::MintProviderClientAssertionRequest, + request_id: &str, +) -> std::result::Result { + let endpoint = token_grant_gateway_endpoint(gateway.url())?; + if endpoint.scheme() != "https" + || body.expected_provider_id.is_empty() + || !crate::provider_credentials::provider_assertion_endpoint(&body.token_endpoint) + { + return Err(gateway_grant_failure(TokenGrantFailureKind::Configuration)); + } + let mut client = connect(&gateway.with_url(endpoint.as_str())) + .await + .map_err(|_| gateway_grant_failure(TokenGrantFailureKind::Unavailable))?; + let expected_provider_id = body.expected_provider_id.clone(); + let response = client + .mint_provider_client_assertion(token_grant_gateway_request(body, Some(request_id))) + .await + .map_err(|status| provider_subject_token_exchange_status(&status, &endpoint))? + .into_inner(); + provider_client_assertion_response(&expected_provider_id, response) +} + +fn provider_client_assertion_response( + expected_provider_id: &str, + response: crate::proto::MintProviderClientAssertionResponse, +) -> std::result::Result { + if expected_provider_id.is_empty() || response.bound_provider_id != expected_provider_id { + return Err(gateway_grant_failure(TokenGrantFailureKind::Configuration)); + } + if response.client_assertion.is_empty() { + return Err(gateway_grant_failure(TokenGrantFailureKind::Unclassified)); + } + Ok(response.client_assertion) +} + +#[cfg(test)] +mod provider_assertion_response_tests { + use super::*; + + #[test] + fn refuses_missing_or_different_gateway_binding() { + for bound_provider_id in ["", "replacement"] { + let response = crate::proto::MintProviderClientAssertionResponse { + client_assertion: "synthetic-assertion".into(), + bound_provider_id: bound_provider_id.into(), + }; + assert!(provider_client_assertion_response("installed", response).is_err()); + } + } +} + /// Exchange a provider credential without retaining gateway diagnostics. /// /// Only a verified TLS connection can establish the provenance of a precise @@ -1684,7 +1739,7 @@ pub async fn exchange_provider_subject_token( let mut client = connect(&gateway.with_url(endpoint.as_str())) .await .map_err(|_| gateway_grant_failure(TokenGrantFailureKind::Unclassified))?; - let request = provider_subject_token_exchange_request( + let request = token_grant_gateway_request( ExchangeProviderSubjectTokenRequest { sandbox_id: sandbox_id.to_string(), provider: provider.to_string(), @@ -1702,13 +1757,10 @@ pub async fn exchange_provider_subject_token( )) } -/// Attach the exchange's request ID as `x-request-id` gRPC metadata. +/// Attach the grant's request ID as `x-request-id` gRPC metadata. /// /// A value that is not valid metadata is omitted, never rewritten. -fn provider_subject_token_exchange_request( - body: ExchangeProviderSubjectTokenRequest, - request_id: Option<&str>, -) -> tonic::Request { +fn token_grant_gateway_request(body: T, request_id: Option<&str>) -> tonic::Request { let mut request = tonic::Request::new(body); if let Some(request_id) = request_id.and_then(|value| value.parse().ok()) { request.metadata_mut().insert("x-request-id", request_id); @@ -1805,10 +1857,8 @@ mod token_grant_exchange_tests { (Some("line\nbreak"), None), (None, None), ] { - let request = provider_subject_token_exchange_request( - ExchangeProviderSubjectTokenRequest::default(), - value, - ); + let request = + token_grant_gateway_request(ExchangeProviderSubjectTokenRequest::default(), value); let carried = request.metadata().get("x-request-id"); assert_eq!(carried.and_then(|value| value.to_str().ok()), expected); } diff --git a/crates/openshell-core/src/provider_credentials.rs b/crates/openshell-core/src/provider_credentials.rs index ab9bd26195..f76168ec43 100644 --- a/crates/openshell-core/src/provider_credentials.rs +++ b/crates/openshell-core/src/provider_credentials.rs @@ -3,7 +3,7 @@ //! Runtime provider credential snapshots. -use crate::secrets::{CredentialPlacement, SecretResolver}; +use crate::secrets::{CredentialPlacement, SecretResolver, grant_placeholder_for_env_key}; use crate::{ endpoint_path::EndpointPathPattern, host_pattern::HostPattern, @@ -24,6 +24,66 @@ pub struct ProviderCredentialSnapshot { pub dynamic_credentials: HashMap, } +impl ProviderCredentialSnapshot { + /// Return the exact workload handle installed for this admitted grant. + pub fn grant_placeholder( + &self, + credential: &crate::proto::ProviderProfileCredential, + ) -> Option<&str> { + let (key, expected) = grant_placeholder(credential)?; + self.child_env + .get(key) + .filter(|value| **value == expected) + .map(String::as_str) + } +} + +fn grant_placeholder( + credential: &crate::proto::ProviderProfileCredential, +) -> Option<(&str, String)> { + if !credential + .auth_style + .trim() + .eq_ignore_ascii_case("placeholder") + || credential.token_grant_provider_id.is_empty() + || credential.token_grant_owners.is_empty() + { + return None; + } + let [key] = credential.env_vars.as_slice() else { + return None; + }; + let grant = credential.token_grant.as_ref()?; + Some(( + key, + grant_placeholder_for_env_key(key, &grant.workload_credential_handle)?, + )) +} + +/// Project only unambiguous handles. Keep grant material out of static +/// resolver snapshots and never override another provider's environment. +fn project_grant_placeholders( + child_env: &mut HashMap, + credentials: &HashMap, +) { + let mut by_key = HashMap::<&str, Option>::new(); + for (key, handle) in credentials.values().filter_map(grant_placeholder) { + by_key + .entry(key) + .and_modify(|existing| { + if existing.as_ref() != Some(&handle) { + *existing = None; + } + }) + .or_insert(Some(handle)); + } + for (key, handle) in by_key { + if let Some(handle) = handle { + child_env.entry(key.to_string()).or_insert(handle); + } + } +} + /// One atomic workload-facing snapshot and its local installation identity. /// /// Only the prepared environment crosses an isolation boundary; resolver @@ -59,6 +119,22 @@ struct ProviderCredentialStateInner { requires_stable_placeholders: bool, } +/// Whether this exact URL can identify a provider assertion recipient. +/// Reject URL parser normalization so the signed audience and transport URL +/// identify the same destination byte for byte. +#[must_use] +pub fn provider_assertion_endpoint(endpoint: &str) -> bool { + url::Url::parse(endpoint).is_ok_and(|url| { + url.as_str() == endpoint + && url.scheme() == "https" + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none() + }) +} + #[derive(Debug)] struct StaticCredentialIdentityEpoch { identity: String, @@ -129,12 +205,13 @@ impl ProviderCredentialState { dynamic_credentials: HashMap, ) -> Self { let (_, dynamic_credentials) = split_static_credential_metadata(dynamic_credentials); - let (child_env, generation_resolver, current_resolver) = + let (mut child_env, generation_resolver, current_resolver) = SecretResolver::from_provider_env_for_current_revision( env, credential_expires_at_ms, revision, ); + project_grant_placeholders(&mut child_env, &dynamic_credentials); let snapshot = Arc::new(ProviderCredentialSnapshot { installation_id: uuid::Uuid::new_v4().to_string(), revision, @@ -208,13 +285,14 @@ impl ProviderCredentialState { &placement_metadata, )?; let stable_handles = static_credential_stable_handles(&static_credential_bindings); - let (child_env, generation_resolver, current_resolver) = + let (mut child_env, generation_resolver, current_resolver) = SecretResolver::from_provider_env_for_current_revision_with_stable_handles( env, credential_expires_at_ms, revision, &stable_handles, ); + project_grant_placeholders(&mut child_env, &dynamic_credentials); let snapshot = Arc::new(ProviderCredentialSnapshot { installation_id: uuid::Uuid::new_v4().to_string(), revision, @@ -751,6 +829,7 @@ impl ProviderCredentialState { credential_expires_at_ms, revision, ); + project_grant_placeholders(&mut child_env, &dynamic_credentials); let mut inner = self .inner .write() @@ -849,6 +928,7 @@ impl ProviderCredentialState { revision, &stable_handles, ); + project_grant_placeholders(&mut child_env, &dynamic_credentials); let mut inner = self .inner .write() @@ -1312,6 +1392,72 @@ mod tests { format!("e30.e30.OPENSHELL-RESOLVE-ENV-{suffix}") } + #[test] + fn grant_placeholder_projects_only_current_unambiguous_handle_without_a_secret() { + let credential = crate::proto::ProviderProfileCredential { + auth_style: "placeholder".into(), + env_vars: vec!["API_TOKEN".into()], + token_grant_provider_id: "provider-id".into(), + token_grant_owners: vec!["endpoint-owner".into()], + token_grant: Some(crate::proto::ProviderCredentialTokenGrant { + workload_credential_handle: "a".repeat(64), + ..Default::default() + }), + ..Default::default() + }; + let grants = HashMap::from([ + ("default".to_string(), credential.clone()), + ("override".to_string(), credential.clone()), + ]); + let state = ProviderCredentialState::from_environment( + 1, + HashMap::new(), + HashMap::new(), + grants.clone(), + ); + let first = state.snapshot(); + let handle = first.grant_placeholder(&credential).unwrap(); + assert_eq!(handle, first.child_env["API_TOKEN"]); + assert!(state.inner.read().unwrap().current_resolver.is_none()); + + state.install_environment(2, HashMap::new(), HashMap::new(), grants); + assert_eq!( + state.snapshot().grant_placeholder(&credential), + Some(handle) + ); + + let mut replacement = credential.clone(); + replacement + .token_grant + .as_mut() + .unwrap() + .workload_credential_handle = "b".repeat(64); + state.install_environment( + 3, + HashMap::new(), + HashMap::new(), + HashMap::from([("replacement".to_string(), replacement.clone())]), + ); + assert!(state.snapshot().grant_placeholder(&credential).is_none()); + assert!(state.snapshot().grant_placeholder(&replacement).is_some()); + + state.install_environment( + 4, + HashMap::new(), + HashMap::new(), + HashMap::from([ + ("original".to_string(), credential.clone()), + ("replacement".to_string(), replacement.clone()), + ]), + ); + assert!(state.snapshot().child_env.is_empty()); + assert!(state.snapshot().grant_placeholder(&replacement).is_none()); + + state.install_environment(5, HashMap::new(), HashMap::new(), HashMap::new()); + assert!(state.snapshot().child_env.is_empty()); + assert!(state.snapshot().grant_placeholder(&credential).is_none()); + } + #[test] fn body_classification_distinguishes_literal_foreign_bound_and_revoked() { use crate::secrets::body::BodyCredentialError; diff --git a/crates/openshell-core/src/secrets.rs b/crates/openshell-core/src/secrets.rs index 6f4856be4c..a797fc01c1 100644 --- a/crates/openshell-core/src/secrets.rs +++ b/crates/openshell-core/src/secrets.rs @@ -71,11 +71,63 @@ pub fn header_value_contains_reserved_credential_marker(value: &str) -> bool { contains_raw_reserved_marker(&decoded) } +/// Detect the reserved grant namespace in URL and other textual request locations. +pub fn contains_grant_placeholder(value: &str) -> bool { + fn contains_raw(value: &str) -> bool { + [PLACEHOLDER_PREFIX, PROVIDER_ALIAS_MARKER] + .iter() + .any(|marker| { + value.match_indices(marker).any(|(start, _)| { + let key = &value[start + marker.len()..]; + let end = key + .bytes() + .position(|byte| !is_env_key_char(byte)) + .unwrap_or(key.len()); + split_grant_env_key(&key[..end]).is_some() + }) + }) + } + contains_raw(value) || contains_raw(&percent_decode(value)) +} + +/// Detect a grant handle in any header form supported by the header rewriter. +pub fn header_contains_grant_placeholder(value: &str) -> bool { + contains_grant_placeholder(value) + || decode_basic_auth_value(value.trim()) + .is_some_and(|decoded| contains_grant_placeholder(&decoded)) +} + +#[must_use] +pub fn valid_grant_placeholder_env_key(key: &str) -> bool { + key.as_bytes() + .first() + .is_some_and(|byte| byte.is_ascii_alphabetic() || *byte == b'_') + && key.bytes().all(is_env_key_char) + && !uses_reserved_revision_namespace(key) +} + +#[must_use] +pub fn valid_grant_placeholder_header(name: &str) -> bool { + !matches!( + name.to_ascii_lowercase().as_str(), + "host" + | "content-length" + | "transfer-encoding" + | "connection" + | "proxy-connection" + | "proxy-authorization" + | "proxy-authenticate" + | "keep-alive" + | "te" + | "trailer" + | "upgrade" + | "expect" + ) +} + fn basic_auth_token(value: &str) -> Option<&str> { - value - .strip_prefix("Basic ") - .or_else(|| value.strip_prefix("basic ")) - .map(str::trim) + let (scheme, token) = value.split_once(' ')?; + scheme.eq_ignore_ascii_case("Basic").then(|| token.trim()) } fn decode_basic_auth_value(value: &str) -> Option { @@ -674,9 +726,50 @@ impl SecretResolver { pub fn rewrite_header_value( &self, value: &str, + ) -> Result, UnresolvedPlaceholderError> { + self.rewrite_header_value_with(value, &mut |placeholder| { + self.resolve_placeholder(placeholder) + }) + } + + /// Validate that the established header grammar references exactly the + /// current grant handle once. This check has no resolver material. + pub fn matches_grant_header_placeholder(value: &str, expected: &str) -> bool { + let mut matches = 0; + let result = Self::default().rewrite_header_value_with(value, &mut |placeholder| { + if placeholder != expected { + return None; + } + matches += 1; + Some("") + }); + matches == 1 + && result.is_ok_and(|rewritten| { + rewritten + .is_some_and(|value| !header_value_contains_reserved_credential_marker(&value)) + }) + } + + /// Bind exactly one installed handle for a single HTTP header rewrite. + pub fn rewrite_grant_header_value( + value: &str, + handle: &str, + access_token: &str, + ) -> Result, UnresolvedPlaceholderError> { + placeholder_env_key(handle).ok_or_else(|| unresolved("header"))?; + let token = validate_resolved_secret(access_token).map_err(|_| unresolved("header"))?; + Self::default().rewrite_header_value_with(value, &mut |candidate| { + (candidate == handle).then_some(token) + }) + } + + fn rewrite_header_value_with<'a>( + &self, + value: &str, + resolve: &mut impl FnMut(&str) -> Option<&'a str>, ) -> Result, UnresolvedPlaceholderError> { // Direct placeholder match: `x-api-key: openshell:resolve:env:KEY` - if let Some(secret) = self.resolve_placeholder(value.trim()) { + if let Some(secret) = resolve(value.trim()) { return Ok(Some(secret.to_string())); } @@ -685,7 +778,7 @@ impl SecretResolver { // Basic auth decoding: `Basic ` where the decoded content // contains a placeholder (e.g. `user:openshell:resolve:env:PASS`). if let Some(encoded) = basic_auth_token(trimmed) - && let Some(rewritten) = self.rewrite_basic_auth_token(encoded)? + && let Some(rewritten) = self.rewrite_basic_auth_token(encoded, resolve)? { return Ok(Some(format!("Basic {rewritten}"))); } @@ -699,7 +792,7 @@ impl SecretResolver { }; let prefix = &trimmed[..split_at]; let candidate = trimmed[split_at..].trim(); - if let Some(secret) = self.resolve_placeholder(candidate) { + if let Some(secret) = resolve(candidate) { return Ok(Some(format!("{prefix} {secret}"))); } @@ -723,6 +816,17 @@ impl SecretResolver { &self, text: &mut String, location: &'static str, + ) -> Result { + self.rewrite_text_placeholders_with(text, location, &mut |placeholder| { + self.resolve_placeholder(placeholder) + }) + } + + fn rewrite_text_placeholders_with<'a>( + &self, + text: &mut String, + location: &'static str, + resolve: &mut impl FnMut(&str) -> Option<&'a str>, ) -> Result { if !contains_raw_reserved_marker(text) { return Ok(0); @@ -749,7 +853,7 @@ impl SecretResolver { let Some((token_end, token)) = self.credential_token_at(text, abs_start) else { return Err(unresolved(location)); }; - let Some(secret) = self.resolve_placeholder(token) else { + let Some(secret) = resolve(token) else { return Err(self.unresolved_for(location, token)); }; rewritten.push_str(secret); @@ -759,7 +863,7 @@ impl SecretResolver { } if let Some((token_end, token)) = alias_token_at(text, abs_start) { - let Some(secret) = self.resolve_placeholder(token) else { + let Some(secret) = resolve(token) else { return Err(self.unresolved_for(location, token)); }; rewritten.push_str(secret); @@ -825,11 +929,11 @@ impl SecretResolver { /// the decoded `username:password` string, and re-encode. /// /// Returns `None` if decoding fails or no placeholders are found. - fn rewrite_basic_auth_token( + fn rewrite_basic_auth_token<'a>( &self, encoded: &str, + resolve: &mut impl FnMut(&str) -> Option<&'a str>, ) -> Result, UnresolvedPlaceholderError> { - let b64 = base64::engine::general_purpose::STANDARD; let Some(decoded) = decode_basic_auth_token(encoded.trim()) else { return Ok(None); }; @@ -839,13 +943,16 @@ impl SecretResolver { } let mut rewritten = decoded; - let replacements = self.rewrite_text_placeholders_at_location(&mut rewritten, "header")?; + let replacements = + self.rewrite_text_placeholders_with(&mut rewritten, "header", resolve)?; if replacements == 0 { return Ok(None); } - Ok(Some(b64.encode(rewritten.as_bytes()))) + Ok(Some( + base64::engine::general_purpose::STANDARD.encode(rewritten.as_bytes()), + )) } } @@ -957,7 +1064,9 @@ fn placeholder_env_key(token: &str) -> Option<&str> { } pub fn uses_reserved_revision_namespace(key: &str) -> bool { - split_revisioned_env_key(key).is_some() || split_stable_env_key(key).is_some() + split_revisioned_env_key(key).is_some() + || split_stable_env_key(key).is_some() + || split_grant_env_key(key).is_some() } fn split_revisioned_env_key(key: &str) -> Option<(&str, &str)> { @@ -974,7 +1083,14 @@ fn split_revisioned_env_key(key: &str) -> Option<(&str, &str)> { } fn split_stable_env_key(key: &str) -> Option<(&str, &str)> { - let suffix = key.strip_prefix('s')?; + split_handle_env_key(key.strip_prefix('s')?) +} + +fn split_grant_env_key(key: &str) -> Option<(&str, &str)> { + split_handle_env_key(key.strip_prefix('g')?) +} + +fn split_handle_env_key(suffix: &str) -> Option<(&str, &str)> { let (handle, env_key) = suffix.split_once('_')?; if handle.len() != 64 || !handle @@ -1015,6 +1131,14 @@ pub(crate) fn placeholder_for_env_key_for_stable_handle(key: &str, handle: &str) format!("{PLACEHOLDER_PREFIX}s{handle}_{key}") } +pub(crate) fn grant_placeholder_for_env_key(key: &str, handle: &str) -> Option { + if !valid_grant_placeholder_env_key(key) { + return None; + } + let suffix = format!("g{handle}_{key}"); + (split_grant_env_key(&suffix) == Some((handle, key))).then(|| placeholder_for_env_key(&suffix)) +} + // --------------------------------------------------------------------------- // Secret validation (F1 — CWE-113) // --------------------------------------------------------------------------- @@ -1424,7 +1548,16 @@ pub fn rewrite_http_header_block( .position(|w| w == b"\r\n\r\n") .map_or(raw.len(), |p| raw.len().min(p + 4 + 256)); let header_region = String::from_utf8_lossy(&raw[..scan_end]); - if contains_reserved_credential_marker(&header_region) { + // A detached grant has no resolver state. Detect its opaque handle + // in Basic's decoded value before the header can reach the origin. + if contains_reserved_credential_marker(&header_region) + || header_region + .split("\r\n") + .skip(1) + .take_while(|line| !line.is_empty()) + .filter_map(|line| line.split_once(':')) + .any(|(_, value)| header_contains_grant_placeholder(value)) + { return Err(unresolved("header")); } return Ok(RewriteResult { @@ -1644,6 +1777,70 @@ mod tests { )); } + #[test] + fn grant_header_requires_one_exact_handle_in_the_existing_header_grammar() { + let current = grant_placeholder_for_env_key("API_TOKEN", &"a".repeat(64)).unwrap(); + let stale = grant_placeholder_for_env_key("API_TOKEN", &"b".repeat(64)).unwrap(); + let basic = |user_password: &str| { + format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(user_password) + ) + }; + for (value, expected) in [ + (current.clone(), "issued".to_string()), + (format!("Bearer {current}"), "Bearer issued".to_string()), + ( + basic(&format!("client-name:{current}")).replacen("Basic", "bAsIc", 1), + basic("client-name:issued"), + ), + ] { + assert!(SecretResolver::matches_grant_header_placeholder( + &value, ¤t + )); + assert_eq!( + SecretResolver::rewrite_grant_header_value(&value, ¤t, "issued") + .unwrap() + .as_deref(), + Some(expected.as_str()), + ); + } + assert!( + SecretResolver::rewrite_grant_header_value(¤t, ¤t, "bad\rvalue").is_err() + ); + for value in [ + stale.clone(), + basic(&format!("client-name:{stale}")), + basic(&format!("{current}:{current}")), + format!("{current} {current}"), + placeholder_for_env_key("API_TOKEN"), + ] { + assert!(!SecretResolver::matches_grant_header_placeholder( + &value, ¤t + )); + } + assert!(header_contains_grant_placeholder(&basic(&format!( + "client-name:{stale}" + )))); + assert!(contains_grant_placeholder(&percent_encode_query(¤t))); + assert!( + SecretResolver::default() + .resolve_placeholder(¤t) + .is_none() + ); + } + + #[test] + fn detached_grant_basic_handle_cannot_pass_a_relay_without_a_resolver() { + let handle = grant_placeholder_for_env_key("API_TOKEN", &"a".repeat(64)).unwrap(); + let encoded = + base64::engine::general_purpose::STANDARD.encode(format!("client-name:{handle}")); + let request = format!( + "GET /resource HTTP/1.1\r\nHost: api.example\r\nAuthorization: bAsIc {encoded}\r\n\r\n" + ); + assert!(rewrite_http_header_block(request.as_bytes(), None).is_err()); + } + fn placement_resolver(placement: CredentialPlacement) -> SecretResolver { let (_, resolver) = SecretResolver::from_provider_env_for_revision( HashMap::from([("TOKEN".to_string(), "secret-value".to_string())]), diff --git a/crates/openshell-core/src/secrets_body.rs b/crates/openshell-core/src/secrets_body.rs index c9464f8e50..22538f06ec 100644 --- a/crates/openshell-core/src/secrets_body.rs +++ b/crates/openshell-core/src/secrets_body.rs @@ -6,8 +6,8 @@ use super::{ CREDENTIAL_MARKER_SCAN_TAIL_BYTES, PLACEHOLDER_PREFIX, PROVIDER_ALIAS_MARKER, SecretResolver, alias_env_key, is_alias_token_char, is_env_key_char, now_ms, placeholder_env_key, - placeholder_for_env_key, revisioned_placeholder_parts, split_revisioned_env_key, - stable_placeholder_parts, validate_resolved_secret, + placeholder_for_env_key, revisioned_placeholder_parts, split_grant_env_key, + split_revisioned_env_key, stable_placeholder_parts, validate_resolved_secret, }; use std::collections::{HashMap, HashSet}; use std::fmt; @@ -109,6 +109,11 @@ impl BodyCredentialClassifier { if key.is_empty() || !key.bytes().all(is_env_key_char) { return Err(BodyCredentialError::InvalidToken); } + // Grant handles are reserved for request headers. Their absence from + // the static inventory must never classify them as harmless text. + if split_grant_env_key(key).is_some() { + return Err(BodyCredentialError::KnownUnavailable); + } // Reserved identity namespaces must parse completely, including u64 overflow. if let Some(suffix) = token.strip_prefix(PLACEHOLDER_PREFIX) && split_revisioned_env_key(suffix).is_some() @@ -404,7 +409,17 @@ mod tests { known: HashSet::from(["KEY".to_owned()]), ..BodyCredentialClassifier::default() }; - for token in ["openshell:resolve:env:KEY", "sk-OPENSHELL-RESOLVE-ENV-KEY"] { + let grant = super::super::grant_placeholder_for_env_key("OTHER", &"a".repeat(64)).unwrap(); + let grant_alias = format!( + "sk-OPENSHELL-RESOLVE-ENV-{}", + grant.strip_prefix(PLACEHOLDER_PREFIX).unwrap() + ); + for token in [ + "openshell:resolve:env:KEY", + "sk-OPENSHELL-RESOLVE-ENV-KEY", + grant.as_str(), + grant_alias.as_str(), + ] { for token in [token.to_owned(), encode_all(token)] { let body = format!("{} {token}!", "safe ".repeat(100)); for split in 0..body.len() { diff --git a/crates/openshell-gateway-interceptors/src/proto_json.rs b/crates/openshell-gateway-interceptors/src/proto_json.rs index f34c791adc..3e2c2de5db 100644 --- a/crates/openshell-gateway-interceptors/src/proto_json.rs +++ b/crates/openshell-gateway-interceptors/src/proto_json.rs @@ -402,6 +402,10 @@ mod tests { ("openshell.v1.IssueSandboxTokenResponse", "token"), ("openshell.v1.RefreshSandboxTokenResponse", "token"), ("openshell.v1.ExtensionServiceCredential", "token"), + ( + "openshell.v1.MintProviderClientAssertionResponse", + "client_assertion", + ), ("openshell.v1.CreateSshSessionResponse", "token"), ("openshell.v1.RevokeSshSessionRequest", "token"), ("openshell.v1.TcpForwardInit", "authorization_token"), diff --git a/crates/openshell-otel-test-support/src/tls.rs b/crates/openshell-otel-test-support/src/tls.rs index 7fb7d272b0..7dd08b56bf 100644 --- a/crates/openshell-otel-test-support/src/tls.rs +++ b/crates/openshell-otel-test-support/src/tls.rs @@ -13,7 +13,7 @@ pub struct PemIdentity { pub key_pem: String, } -/// A CA with a `localhost` server certificate and a client certificate, all +/// A CA with a server certificate and a client certificate, all /// freshly generated. #[derive(Clone, Debug)] pub struct TestPki { @@ -25,6 +25,12 @@ pub struct TestPki { impl TestPki { #[must_use] pub fn generate() -> Self { + Self::for_server("localhost") + } + + /// Issue the server certificate for a particular DNS name. + #[must_use] + pub fn for_server(host: &str) -> Self { let ca_key = pki::generate_keypair().unwrap(); let mut ca_params = CertificateParams::new(Vec::::new()).unwrap(); ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); @@ -34,7 +40,7 @@ impl TestPki { server: issue( &ca, &ca_key, - vec!["localhost".to_string()], + vec![host.to_owned()], ExtendedKeyUsagePurpose::ServerAuth, ), client: issue( @@ -91,7 +97,9 @@ impl ServerTls { } } - pub(crate) fn rustls_config(&self) -> std::sync::Arc { + /// Configure a rustls server with this identity and client trust. + #[must_use] + pub fn rustls_config(&self) -> std::sync::Arc { use rustls::pki_types::pem::PemObject as _; use rustls::pki_types::{CertificateDer, PrivateKeyDer}; diff --git a/crates/openshell-providers/src/lib.rs b/crates/openshell-providers/src/lib.rs index 87ac5d8007..7258ff5fa3 100644 --- a/crates/openshell-providers/src/lib.rs +++ b/crates/openshell-providers/src/lib.rs @@ -19,10 +19,12 @@ pub use openshell_core::proto::Provider; pub use context::{DiscoveryContext, RealDiscoveryContext}; pub use discovery::{discover_from_profile, discover_with_spec}; pub use profiles::{ - CredentialRefreshProfile, ProfileError, ProfileValidationDiagnostic, ProviderTypeProfile, + CredentialRefreshProfile, GATEWAY_ASSERTION_PROFILE_CAPABILITY, ProfileError, + ProfileValidationDiagnostic, ProviderTypeProfile, TokenGrantAssertionSource, is_gateway_mintable_strategy, normalize_profile_id, parse_profile_json, parse_profile_yaml, - profile_to_json, profile_to_yaml, profiles_to_json, profiles_to_yaml, strategy_output_env_key, - strategy_output_spec, strategy_primary_env_key, validate_profile_set, + profile_to_json, profile_to_yaml, profile_uses_gateway_assertion, profiles_to_json, + profiles_to_yaml, strategy_output_env_key, strategy_output_spec, strategy_primary_env_key, + unknown_token_grant_type_diagnostic, validate_profile_set, }; pub const VERTEX_AI_PROJECT_ID_KEY: &str = "VERTEX_AI_PROJECT_ID"; diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index b56dc2c7e4..fddfbcb08c 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -170,6 +170,54 @@ fn profile_duration_is_legacy_or_absent(value: &ProfileDurationWkt) -> bool { !matches!(value, ProfileDurationWkt::Present(_)) } +#[derive(Debug, Clone, Copy, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TokenGrantAssertionSource { + #[default] + Spiffe, + OpenshellGateway, +} + +/// Profile clients send this gRPC metadata key with value `1` when they can +/// preserve the gateway assertion source on profile reads and writes. +pub const GATEWAY_ASSERTION_PROFILE_CAPABILITY: &str = + "openshell-profile-openshell-gateway-assertion-source"; + +/// Check the raw profile before conversion can default an unknown grant to client credentials. +pub fn unknown_token_grant_type_diagnostic( + source: &str, + profile: &ProviderProfile, +) -> Option { + let index = profile.credentials.iter().position(|credential| { + credential.token_grant.as_ref().is_some_and(|grant| { + ProviderCredentialTokenGrantType::try_from(grant.grant_type).is_err() + }) + })?; + Some(ProfileValidationDiagnostic::error( + source, + &profile.id, + format!("credentials[{index}].token_grant.grant_type"), + "unknown provider token grant type", + )) +} + +/// Whether a wire profile requires a reader that preserves the identity source. +#[must_use] +pub fn profile_uses_gateway_assertion(profile: &ProviderProfile) -> bool { + profile.credentials.iter().any(|credential| { + credential.token_grant.as_ref().is_some_and(|grant| { + grant.grant_type == ProviderCredentialTokenGrantType::OpenshellClientCredentials as i32 + }) + }) +} + +impl TokenGrantAssertionSource { + #[allow(clippy::trivially_copy_pass_by_ref)] + fn is_spiffe(&self) -> bool { + *self == Self::Spiffe + } +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct TokenGrantProfile { #[serde( @@ -179,6 +227,8 @@ pub struct TokenGrantProfile { skip_serializing_if = "is_client_credentials_grant" )] pub grant_type: ProviderCredentialTokenGrantType, + #[serde(default, skip_serializing_if = "TokenGrantAssertionSource::is_spiffe")] + pub assertion_source: TokenGrantAssertionSource, pub token_endpoint: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub audience: String, @@ -209,6 +259,7 @@ pub struct TokenGrantProfile { impl PartialEq for TokenGrantProfile { fn eq(&self, other: &Self) -> bool { self.grant_type == other.grant_type + && self.assertion_source == other.assertion_source && self.token_endpoint == other.token_endpoint && self.audience == other.audience && self.jwt_svid_audience == other.jwt_svid_audience @@ -1004,6 +1055,7 @@ impl ProviderTypeProfile { path_template: credential.path_template.clone(), token_grant: credential.token_grant.as_ref().map(token_grant_to_proto), token_grant_owners: Vec::new(), + token_grant_provider_id: String::new(), }) .collect(), files: self @@ -1036,6 +1088,19 @@ impl ProviderTypeProfile { pub fn validate_before_lowering(&self, source: &str) -> Vec { let mut diagnostics = Vec::new(); + for credential in &self.credentials { + if credential.token_grant.as_ref().is_some_and(|grant| { + grant.assertion_source == TokenGrantAssertionSource::OpenshellGateway + && !is_client_credentials_grant(&grant.grant_type) + }) { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + &self.id, + "credentials.token_grant.assertion_source", + "openshell_gateway requires client_credentials", + )); + } + } for (index, endpoint) in self.endpoints.iter().enumerate() { collect_mcp_profile_diagnostics(source, &self.id, index, endpoint, &mut diagnostics); if endpoint.rules.as_ref().is_some_and(Vec::is_empty) { @@ -1121,6 +1186,9 @@ impl CredentialProfile { /// logical profile name instead. #[must_use] pub fn accepted_stored_keys(&self) -> Vec<&str> { + if self.auth_style.trim().eq_ignore_ascii_case("placeholder") { + return Vec::new(); + } if self.env_vars.is_empty() { let name = self.name.trim(); return (!name.is_empty()).then_some(name).into_iter().collect(); @@ -1300,8 +1368,8 @@ fn effective_token_grant_type( } #[allow(clippy::trivially_copy_pass_by_ref)] -fn is_client_credentials_grant(value: &ProviderCredentialTokenGrantType) -> bool { - effective_token_grant_type(*value) == ProviderCredentialTokenGrantType::ClientCredentials +fn is_client_credentials_grant(grant_type: &ProviderCredentialTokenGrantType) -> bool { + effective_token_grant_type(*grant_type) == ProviderCredentialTokenGrantType::ClientCredentials } fn deserialize_category<'de, D>(deserializer: D) -> Result @@ -1444,6 +1512,7 @@ pub fn provider_token_grant_type_to_yaml( match grant_type { ProviderCredentialTokenGrantType::TokenExchange => "token_exchange", ProviderCredentialTokenGrantType::ClientCredentials + | ProviderCredentialTokenGrantType::OpenshellClientCredentials | ProviderCredentialTokenGrantType::Unspecified => "client_credentials", } } @@ -1529,11 +1598,20 @@ fn credential_refresh_to_proto(refresh: &CredentialRefreshProfile) -> ProviderCr fn token_grant_from_proto( token_grant: &openshell_core::proto::ProviderCredentialTokenGrant, ) -> TokenGrantProfile { + let (grant_type, assertion_source) = + match ProviderCredentialTokenGrantType::try_from(token_grant.grant_type) { + Ok(ProviderCredentialTokenGrantType::OpenshellClientCredentials) => ( + ProviderCredentialTokenGrantType::ClientCredentials, + TokenGrantAssertionSource::OpenshellGateway, + ), + grant_type => ( + effective_token_grant_type(grant_type.unwrap_or_default()), + TokenGrantAssertionSource::Spiffe, + ), + }; TokenGrantProfile { - grant_type: effective_token_grant_type( - ProviderCredentialTokenGrantType::try_from(token_grant.grant_type) - .unwrap_or(ProviderCredentialTokenGrantType::ClientCredentials), - ), + grant_type, + assertion_source, token_endpoint: token_grant.token_endpoint.clone(), audience: token_grant.audience.clone(), jwt_svid_audience: token_grant.jwt_svid_audience.clone(), @@ -1558,7 +1636,17 @@ fn token_grant_to_proto( token_grant: &TokenGrantProfile, ) -> openshell_core::proto::ProviderCredentialTokenGrant { openshell_core::proto::ProviderCredentialTokenGrant { - grant_type: token_grant.grant_type as i32, + grant_type: match token_grant.assertion_source { + TokenGrantAssertionSource::Spiffe => token_grant.grant_type as i32, + TokenGrantAssertionSource::OpenshellGateway + if is_client_credentials_grant(&token_grant.grant_type) => + { + ProviderCredentialTokenGrantType::OpenshellClientCredentials as i32 + } + // Infallible lowering must not normalize an invalid combination + // into a supported grant. Supported receivers reject unknown values. + TokenGrantAssertionSource::OpenshellGateway => -1, + }, token_endpoint: token_grant.token_endpoint.clone(), audience: token_grant.audience.clone(), jwt_svid_audience: token_grant.jwt_svid_audience.clone(), @@ -1578,6 +1666,7 @@ fn token_grant_to_proto( .as_ref() .map(token_grant_subject_token_to_proto), requested_token_type: token_grant.requested_token_type.clone(), + workload_credential_handle: String::new(), } } @@ -2367,6 +2456,23 @@ pub fn validate_profile_set( let auth_style = credential.auth_style.trim().to_ascii_lowercase(); match auth_style.as_str() { "" | "basic" => {} + "placeholder" => { + if credential.token_grant.is_none() { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.auth_style", + "auth_style placeholder requires a token_grant", + )); + } + if !matches!(credential.env_vars.as_slice(), [key] if openshell_core::secrets::valid_grant_placeholder_env_key(key)) + { + diagnostics.push(ProfileValidationDiagnostic::error( + source, profile_id, "credentials.env_vars", + "auth_style placeholder requires exactly one valid environment variable", + )); + } + } "bearer" | "header" => { if credential.header_name.trim().is_empty() { diagnostics.push(ProfileValidationDiagnostic::error( @@ -2729,7 +2835,35 @@ pub fn validate_profile_set( .credentials .iter() .any(|credential| credential.token_grant.is_some()); + let has_placeholder_grant = profile.credentials.iter().any(|credential| { + credential.token_grant.is_some() + && credential + .auth_style + .trim() + .eq_ignore_ascii_case("placeholder") + }); + if has_placeholder_grant && profile.endpoints.is_empty() { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "endpoints", + "placeholder token_grant credentials require a REST endpoint with TLS inspection", + )); + } for (index, endpoint) in profile.endpoints.iter().enumerate() { + if has_placeholder_grant + && (L7Protocol::parse(&endpoint.protocol) != Some(L7Protocol::Rest) + || !endpoint.tls.is_empty() + || endpoint.allow_uninspected_credentials + || !endpoint.credential_signing.is_empty()) + { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + format!("endpoints[{index}]"), + "placeholder token_grant credentials require protocol: rest with TLS inspection and no credential signing", + )); + } if !endpoint_is_valid(endpoint) { diagnostics.push(ProfileValidationDiagnostic::error( source, @@ -3292,8 +3426,26 @@ fn validate_token_grant_subject_token( let grant_type = effective_token_grant_type(token_grant.grant_type); let mut diagnostics = Vec::new(); + if token_grant.assertion_source == TokenGrantAssertionSource::OpenshellGateway + && (!is_client_credentials_grant(&token_grant.grant_type) + || !openshell_core::provider_credentials::provider_assertion_endpoint( + &token_grant.token_endpoint, + ) + || !token_grant.jwt_svid_audience.is_empty() + || !token_grant.client_assertion_type.is_empty() + || !token_grant.requested_token_type.is_empty()) + { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.token_grant", + "assertion_source openshell_gateway requires client_credentials, a canonical HTTPS token_endpoint, and default assertion type and audience; requested_token_type is only valid for token_exchange", + )); + } + match grant_type { - ProviderCredentialTokenGrantType::ClientCredentials => { + ProviderCredentialTokenGrantType::ClientCredentials + | ProviderCredentialTokenGrantType::OpenshellClientCredentials => { if token_grant.subject_token.is_some() { diagnostics.push(ProfileValidationDiagnostic::error( source, @@ -3654,16 +3806,39 @@ fn validate_token_grant_endpoint(token_endpoint: &str) -> Result<(), String> { fn validate_token_grant_auth_style(credential: &CredentialProfile) -> Result<(), String> { match credential.auth_style.trim().to_ascii_lowercase().as_str() { "" | "bearer" | "header" => Ok(()), - _ => Err("token_grant credentials support auth_style bearer or header".to_string()), + "placeholder" + if credential.query_param.trim().is_empty() + && credential.path_template.trim().is_empty() + && credential.refresh.is_none() => + { + Ok(()) + } + "placeholder" => Err( + "placeholder token_grant credentials cannot set query_param, path_template, or refresh" + .to_string(), + ), + _ => Err( + "token_grant credentials support auth_style bearer, header, or placeholder".to_string(), + ), } } fn validate_token_grant_header_name(credential: &CredentialProfile) -> Result<(), String> { let header_name = match credential.auth_style.trim().to_ascii_lowercase().as_str() { - "" | "bearer" if credential.header_name.trim().is_empty() => "Authorization", - "" | "bearer" | "header" => credential.header_name.trim(), + "" | "bearer" | "placeholder" if credential.header_name.trim().is_empty() => { + "Authorization" + } + "" | "bearer" | "header" | "placeholder" => credential.header_name.trim(), _ => return Ok(()), }; + if credential + .auth_style + .trim() + .eq_ignore_ascii_case("placeholder") + && !openshell_core::secrets::valid_grant_placeholder_header(header_name) + { + return Err("invalid placeholder token_grant header_name".to_string()); + } if header_name.is_empty() { return Ok(()); } @@ -5357,6 +5532,98 @@ credentials: ); } + #[test] + fn token_grant_discriminators_round_trip_without_serializing_defaults() { + let document = |fields: &str| { + format!( + r" +id: signed-provider +display_name: Signed Provider +credentials: + - name: access + auth_style: bearer + token_grant: {{token_endpoint: https://issuer.example/token, {fields}}} +" + ) + }; + let gateway = "grant_type: client_credentials, assertion_source: openshell_gateway"; + let yaml = document(gateway); + let profile = parse_profile_yaml(&yaml).unwrap(); + let grant = profile.credentials[0].token_grant.as_ref().unwrap(); + assert_eq!( + grant.grant_type, + ProviderCredentialTokenGrantType::ClientCredentials + ); + assert_eq!( + grant.assertion_source, + super::TokenGrantAssertionSource::OpenshellGateway + ); + for (fields, expected_wire) in [ + ("", 1), + ( + "grant_type: client_credentials, assertion_source: spiffe", + 1, + ), + ("grant_type: token_exchange", 2), + (gateway, 3), + ] { + let wire = parse_profile_yaml(&document(fields)).unwrap().to_proto(); + assert_eq!( + wire.credentials[0].token_grant.as_ref().unwrap().grant_type, + expected_wire + ); + let decoded = ProviderTypeProfile::from_proto(&wire); + let yaml = profile_to_yaml(&decoded).unwrap(); + let json = profile_to_json(&decoded).unwrap(); + for export in [&yaml, &json] { + assert_eq!(export.contains("grant_type"), expected_wire == 2); + assert_eq!(export.contains("assertion_source"), expected_wire == 3); + } + assert_eq!(parse_profile_yaml(&yaml).unwrap().to_proto(), wire); + assert_eq!(parse_profile_json(&json).unwrap().to_proto(), wire); + } + assert_eq!(token_grant_from_proto(&token_grant_to_proto(grant)), *grant); + let invalid_combination = + parse_profile_yaml(&yaml.replace("client_credentials", "token_exchange")).unwrap(); + assert!( + !invalid_combination + .validate_before_lowering("test.yaml") + .is_empty() + ); + let invalid_wire = invalid_combination.to_proto(); + assert!( + ProviderCredentialTokenGrantType::try_from( + invalid_wire.credentials[0] + .token_grant + .as_ref() + .unwrap() + .grant_type + ) + .is_err() + ); + for invalid in [ + invalid_combination, + parse_profile_yaml(&document(&format!( + "{gateway}, jwt_svid_audience: elsewhere" + ))) + .unwrap(), + ] { + let diagnostics = validate_profile_set(&[("test.yaml".into(), invalid)]); + assert_eq!( + diagnostics + .iter() + .filter(|d| d.field == "credentials.token_grant") + .count(), + 1 + ); + } + assert!(parse_profile_yaml(&yaml.replace("openshell_gateway", "unknown_issuer")).is_err()); + assert!( + parse_profile_yaml(&yaml.replace("client_credentials", "openshell_client_credentials")) + .is_err() + ); + } + #[test] fn token_grant_audience_overrides_round_trip_through_proto() { let profile = parse_profile_yaml( @@ -5616,16 +5883,14 @@ credentials: ); } - #[test] - fn validate_profile_set_rejects_plain_http_token_endpoint() { - for token_endpoint in [ - "http://auth.example.com/token", - "http://token-issuer.default.svc.evil.com/token", - ] { - let profile = parse_profile_yaml(&format!( - r" -id: insecure-token-grant -display_name: Insecure Token Grant + fn token_endpoint_diagnostics( + token_endpoint: &str, + assertion_source: super::TokenGrantAssertionSource, + ) -> Vec { + let mut profile = parse_profile_yaml(&format!( + r" +id: token-grant +display_name: Token Grant credentials: - name: access_token auth_style: bearer @@ -5634,15 +5899,28 @@ credentials: token_endpoint: {token_endpoint} audience: api://default " - )) - .expect("profile should parse"); + )) + .unwrap(); + profile.credentials[0] + .token_grant + .as_mut() + .unwrap() + .assertion_source = assertion_source; + validate_profile_set(&[("grant.yaml".into(), profile)]) + } - let diagnostics = validate_profile_set(&[("insecure.yaml".to_string(), profile)]); + #[test] + fn validate_profile_set_rejects_plain_http_token_endpoint() { + for endpoint in [ + "http://auth.example.com/token", + "http://token-issuer.default.svc.evil.com/token", + ] { + let diagnostics = + token_endpoint_diagnostics(endpoint, super::TokenGrantAssertionSource::Spiffe); let diagnostic = diagnostics .iter() - .find(|diagnostic| diagnostic.field == "credentials.token_grant.token_endpoint") - .expect("token endpoint diagnostic should be reported"); - + .find(|d| d.field == "credentials.token_grant.token_endpoint") + .unwrap(); assert_eq!( diagnostic.message, "token_endpoint must use https, except http for loopback or in-cluster service hosts" @@ -5651,33 +5929,37 @@ credentials: } #[test] - fn validate_profile_set_allows_https_loopback_and_in_cluster_token_endpoints() { - for token_endpoint in [ - "https://auth.example.com/token", - "http://127.0.0.1:8180/token", - "http://token-issuer.default.svc.cluster.local/token", + fn validate_profile_set_checks_token_endpoints_for_the_assertion_source() { + use super::TokenGrantAssertionSource::{OpenshellGateway, Spiffe}; + for (source, endpoint) in [ + (Spiffe, "https://auth.example.com/token"), + (Spiffe, "http://127.0.0.1:8180/token"), + ( + Spiffe, + "http://token-issuer.default.svc.cluster.local/token", + ), + (OpenshellGateway, "https://auth.example.com/token"), ] { - let profile = parse_profile_yaml(&format!( - r" -id: secure-token-grant -display_name: Secure Token Grant -credentials: - - name: access_token - auth_style: bearer - header_name: Authorization - token_grant: - token_endpoint: {token_endpoint} - audience: api://default -" - )) - .expect("profile should parse"); - - let diagnostics = validate_profile_set(&[("secure.yaml".to_string(), profile)]); + let diagnostics = token_endpoint_diagnostics(endpoint, source); assert!( diagnostics.is_empty(), - "unexpected diagnostics for {token_endpoint}: {diagnostics:?}" + "{source:?} {endpoint}: {diagnostics:?}" ); } + for endpoint in [ + "http://localhost/token", + "https://u:p@issuer.example/token", + "https://issuer.example/token?x=1", + "https://issuer.example/token#x", + "https://issuer.example/other/../token", + ] { + let diagnostics = token_endpoint_diagnostics(endpoint, OpenshellGateway); + let rejected = diagnostics + .iter() + .filter(|d| d.field == "credentials.token_grant") + .count(); + assert_eq!(rejected, 1, "{endpoint}: {diagnostics:?}"); + } } #[test] @@ -5858,8 +6140,8 @@ credentials: } #[test] - fn validate_profile_set_rejects_token_grant_query_or_path_auth_style() { - for auth_style in ["query", "path"] { + fn validate_profile_set_rejects_unsupported_token_grant_auth_style() { + for auth_style in ["basic", "query", "path"] { let profile = parse_profile_yaml(&format!( r" id: unsupported-token-grant-style @@ -5881,11 +6163,90 @@ credentials: assert_eq!( diagnostic.message, - "token_grant credentials support auth_style bearer or header" + "token_grant credentials support auth_style bearer, header, or placeholder" ); } } + #[test] + fn placeholder_token_grant_requires_unambiguous_rest_tls_configuration() { + let mut profile = parse_profile_yaml( + r" +id: placeholder-grant +display_name: Placeholder Grant +credentials: + - name: access_token + auth_style: placeholder + env_vars: [API_TOKEN] + token_grant: + token_endpoint: https://auth.example.com/token + assertion_source: openshell_gateway +endpoints: + - host: api.example.com + port: 443 + path: /v1/** + protocol: rest + access: full +", + ) + .unwrap(); + let lint = |profile: &ProviderTypeProfile| { + validate_profile_set(&[("git.yaml".to_string(), profile.clone())]) + }; + let expect_invalid = |profile: &ProviderTypeProfile, field: &str| { + let diagnostics = lint(profile); + assert!( + diagnostics.iter().any(|error| error.field == field), + "expected a diagnostic for {field}: {diagnostics:?}" + ); + }; + assert!(lint(&profile).is_empty(), "default Authorization is valid"); + profile.credentials[0].header_name = "aUtHoRiZaTiOn".into(); + assert!(lint(&profile).is_empty(), "explicit Authorization is valid"); + + profile.credentials[0].header_name = "X-Api-Key".into(); + assert!(lint(&profile).is_empty(), "a custom header is valid"); + assert!(profile.credentials[0].accepted_stored_keys().is_empty()); + + let mut authored_proto = profile.to_proto(); + authored_proto.credentials[0] + .token_grant + .as_mut() + .unwrap() + .workload_credential_handle = "a".repeat(64); + assert!( + ProviderTypeProfile::from_proto(&authored_proto) + .to_proto() + .credentials[0] + .token_grant + .as_ref() + .unwrap() + .workload_credential_handle + .is_empty() + ); + + let mut invalid = profile.clone(); + invalid.credentials[0].env_vars.push("SECOND_TOKEN".into()); + expect_invalid(&invalid, "credentials.env_vars"); + let mut invalid = profile.clone(); + invalid.credentials[0].token_grant = None; + expect_invalid(&invalid, "credentials.auth_style"); + let mut invalid = profile.clone(); + invalid.credentials[0].header_name = "Proxy-Authorization".into(); + expect_invalid(&invalid, "credentials.header_name"); + let mut invalid = profile.clone(); + invalid.credentials[0].query_param = "token".into(); + expect_invalid(&invalid, "credentials.token_grant.auth_style"); + let mut invalid = profile.clone(); + invalid.endpoints[0].allow_uninspected_credentials = true; + expect_invalid(&invalid, "endpoints[0]"); + let mut invalid = profile.clone(); + invalid.endpoints[0].protocol.clear(); + expect_invalid(&invalid, "endpoints[0]"); + profile.endpoints[0].tls = "skip".into(); + expect_invalid(&profile, "endpoints[0]"); + } + #[test] fn validate_profile_set_requires_header_name_for_token_grant_header_auth_style() { let profile = parse_profile_yaml( diff --git a/crates/openshell-sdk/tests/client_mock.rs b/crates/openshell-sdk/tests/client_mock.rs index 1f3f791a08..aad0a6e61e 100644 --- a/crates/openshell-sdk/tests/client_mock.rs +++ b/crates/openshell-sdk/tests/client_mock.rs @@ -8,6 +8,9 @@ //! TLS and auth code paths are skipped — those are exercised by the CLI's //! `mtls_integration` and OIDC tests. +#[path = "../../../tests/support/unused_gateway_rpcs.rs"] +mod unused_gateway_rpcs; + use openshell_core::proto; use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer}; use openshell_core::proto::sandbox_updates_server::{SandboxUpdates, SandboxUpdatesServer}; @@ -871,12 +874,7 @@ impl OpenShell for TestOpenShell { Ok(Response::new(proto::GetGatewayConfigResponse::default())) } - async fn exchange_provider_subject_token( - &self, - _: tonic::Request, - ) -> Result, Status> { - Err(Status::unimplemented("unused")) - } + unused_gateway_rpcs!(provider_token_grants); async fn update_config( &self, diff --git a/crates/openshell-server/src/auth/sandbox_jwt.rs b/crates/openshell-server/src/auth/sandbox_jwt.rs index 5d37d6c8a5..591ade1d47 100644 --- a/crates/openshell-server/src/auth/sandbox_jwt.rs +++ b/crates/openshell-server/src/auth/sandbox_jwt.rs @@ -55,6 +55,20 @@ pub const REFRESH_SANDBOX_TOKEN_PATH: &str = "/openshell.v1.OpenShell/RefreshSan /// checks. const SPIFFE_SUBJECT_PREFIX: &str = "spiffe://openshell/sandbox/"; +#[derive(Serialize)] +struct ProviderClientAssertionClaims<'a> { + iss: &'a str, + sub: String, + aud: &'a str, + iat: i64, + exp: i64, + jti: String, + caller_kind: ExtensionCallerKind, + sandbox_id: &'a str, + provider_id: &'a str, + credential_key: &'a str, +} + /// Public JSON Web Key Set served by the gateway. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct GatewayJwks { @@ -520,6 +534,43 @@ impl ExtensionJwtIssuer { }) } + /// Sign a provider-specific client assertion after attachment and recipient authorization. + pub(crate) fn mint_provider_client_assertion( + &self, + sandbox_id: &str, + provider_id: &str, + credential_key: &str, + endpoint: &str, + ) -> Result { + if sandbox_id.is_empty() + || provider_id.is_empty() + || credential_key.is_empty() + || !openshell_core::provider_credentials::provider_assertion_endpoint(endpoint) + { + return Err(Status::invalid_argument( + "invalid provider assertion binding", + )); + } + let issued_at = now_secs(); + let claims = ProviderClientAssertionClaims { + iss: &self.issuer, + sub: format!("openshell-sandbox:{sandbox_id}"), + aud: endpoint, + iat: issued_at, + exp: issued_at.saturating_add(60), + jti: uuid::Uuid::new_v4().to_string(), + caller_kind: ExtensionCallerKind::Supervisor, + sandbox_id, + provider_id, + credential_key, + }; + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some(self.kid.clone()); + header.typ = Some("openshell-provider-assertion+jwt".into()); + encode(&header, &claims, &self.encoding_key) + .map_err(|_| Status::internal("failed to sign provider assertion")) + } + pub const fn token_ttl(&self) -> Duration { self.ttl } diff --git a/crates/openshell-server/src/auth/sandbox_methods.rs b/crates/openshell-server/src/auth/sandbox_methods.rs index 498e2a0dd9..1b4600f0ac 100644 --- a/crates/openshell-server/src/auth/sandbox_methods.rs +++ b/crates/openshell-server/src/auth/sandbox_methods.rs @@ -33,6 +33,9 @@ mod tests { assert!(is_sandbox_callable( "/openshell.v1.OpenShell/ExchangeProviderSubjectToken" )); + assert!(is_sandbox_callable( + "/openshell.v1.OpenShell/MintProviderClientAssertion" + )); } #[test] diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index a2df105c55..c56b6c7a08 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -82,6 +82,15 @@ pub struct OpenShellRoot { pub credential_drivers: BTreeMap, } +/// Operator authorization for a provider instance to use the gateway signer. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProviderAssertionRegistration { + pub provider_id: String, + pub credential_key: String, + pub token_endpoint: String, +} + /// `[openshell.gateway]` section. /// /// All fields are `Option` so the loader can tell whether a key was set @@ -91,6 +100,9 @@ pub struct OpenShellRoot { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct GatewayFileSection { + /// Exact recipients allowed to receive gateway-signed provider assertions. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub provider_assertions: Vec, // ── Identity ───────────────────────────────────────────────────────── /// Operator-assigned name for this gateway installation. #[serde(default)] @@ -1512,6 +1524,20 @@ fn parse_and_validate(path: &Path, contents: &str) -> Result return Err(ConfigFileError::MissingVersion), } + for registration in &file.openshell.gateway.provider_assertions { + if registration.provider_id.trim().is_empty() + || registration.credential_key.trim().is_empty() + || !openshell_core::provider_credentials::provider_assertion_endpoint( + ®istration.token_endpoint, + ) + { + return Err(ConfigFileError::InvalidValue { + field: "openshell.gateway.provider_assertions", + message: "each registration requires a provider ID, credential key, and canonical HTTPS token endpoint", + }); + } + } + if file.openshell.gateway.database_url.is_some() { return Err(ConfigFileError::SecretInFile { field: "database_url", diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index 69208348de..09d81b8b65 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -678,6 +678,13 @@ impl OpenShell for OpenShellService { policy::handle_get_sandbox_provider_environment(&self.state, request).await } + async fn mint_provider_client_assertion( + &self, + request: Request, + ) -> Result, Status> { + provider::handle_mint_provider_client_assertion(&self.state, request).await + } + async fn exchange_provider_subject_token( &self, request: Request, diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index ee6f07f9b1..ca0f5b1597 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -3993,6 +3993,50 @@ pub async fn build_provider_environment_snapshot_from_inputs( } } + // Resolve handles only for an actual sandbox attachment, using captured + // server-owned inputs. A different provider's credential refresh does not + // change the process environment required by an unchanged binding. + for credential in provider_environment.dynamic_credentials.values_mut() { + if !credential + .auth_style + .trim() + .eq_ignore_ascii_case("placeholder") + { + continue; + } + let [key] = credential.env_vars.as_slice() else { + continue; + }; + let Some(record) = provider_records + .iter() + .find(|record| record.object_id == credential.token_grant_provider_id) + else { + continue; + }; + let Some(grant) = credential.token_grant.as_mut() else { + continue; + }; + let mut hasher = Sha256::new(); + hasher.update(b"openshell-grant-placeholder-v1"); + for component in [ + sandbox_id.as_str(), + spec.provider_attachment_epoch.as_str(), + record.object_id.as_str(), + credential.name.as_str(), + key.as_str(), + ] { + hasher.update(component.len().to_le_bytes()); + hasher.update(component.as_bytes()); + } + hash_provider_profile_revision( + provider_profile_catalog, + &record.provider.r#type, + &record.provider.profile_workspace, + &mut hasher, + ); + grant.workload_credential_handle = hex::encode(hasher.finalize()); + } + let mut readiness_reason = provider_environment.readiness_reason; if supports_static_credential_bindings { diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 6ef3c3b15d..8adc1fd860 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -1786,6 +1786,8 @@ fn insert_dynamic_credentials_for_profile( if credential.token_grant.is_none() { continue; } + let mut credential = credential.clone(); + credential.token_grant_provider_id = provider_id.to_string(); for (endpoint, owner) in profile.endpoints.iter().zip(&owners) { let ports = if endpoint.ports.is_empty() { std::slice::from_ref(&endpoint.port) @@ -1799,7 +1801,7 @@ fn insert_dynamic_credentials_for_profile( port, &endpoint.path, provider_name, - credential, + &credential, owner, )?; } @@ -2451,7 +2453,7 @@ fn push_dynamic_token_grant_binding( provider_name: provider_name.to_string(), credential_name: credential.name.clone(), // The supervisor selects one grant per case-insensitive header, using - // Authorization when bearer placement omits an explicit destination. + // Authorization when bearer or Basic placement omits an explicit destination. header_name: if credential.header_name.trim().is_empty() { "authorization".to_string() } else { @@ -2545,6 +2547,22 @@ fn active_keys_with_refresh_states( let broker_only_credential_keys = broker_only_provider_credential_keys_for_provider(catalog, provider); let mut keys = active_provider_credential_keys(provider, now_ms, &broker_only_credential_keys); + if let Some(profile) = + get_provider_type_profile_for_scope(catalog, &provider.r#type, &provider.profile_workspace) + { + keys.extend( + profile + .credentials + .iter() + .filter(|credential| { + credential + .auth_style + .trim() + .eq_ignore_ascii_case("placeholder") + }) + .flat_map(|credential| credential.env_vars.iter().cloned()), + ); + } for state in refresh_states { // The primary key plus every co-minted output key this refresh owns, // so a configured-but-not-yet-minted refresh reserves all of them. @@ -2988,6 +3006,7 @@ pub(super) async fn handle_list_provider_profiles( state: &Arc, request: Request, ) -> Result, Status> { + let supports_gateway_assertion = supports_gateway_assertion_profiles(&request); let principal = super::extract_principal(&request)?; let request = request.into_inner(); let workspace = authorize_and_resolve_profile_workspace( @@ -3029,6 +3048,9 @@ pub(super) async fn handle_list_provider_profiles( .map_err(|_| Status::internal("page_size does not fit usize"))?; let has_more = profiles.len() > page_size; profiles.truncate(page_size); + for (_, profile) in &profiles { + require_gateway_assertion_profile_capability(supports_gateway_assertion, profile)?; + } let next_key = if has_more { profiles.last().map(|(key, _)| key.as_str()) } else { @@ -3045,6 +3067,7 @@ pub(super) async fn handle_get_provider_profile( state: &Arc, request: Request, ) -> Result, Status> { + let supports_gateway_assertion = supports_gateway_assertion_profiles(&request); let principal = super::extract_principal(&request)?; let req = request.into_inner(); let workspace = authorize_and_resolve_profile_workspace( @@ -3064,6 +3087,7 @@ pub(super) async fn handle_get_provider_profile( let profile = catalog .get_profile(&id) .ok_or_else(|| Status::not_found("provider profile not found"))?; + require_gateway_assertion_profile_capability(supports_gateway_assertion, &profile)?; Ok(Response::new(ProviderProfileResponse { profile: Some(profile), @@ -3074,9 +3098,17 @@ pub(super) async fn handle_import_provider_profiles( state: &Arc, request: Request, ) -> Result, Status> { + let supports_gateway_assertion = supports_gateway_assertion_profiles(&request); let principal = super::extract_principal(&request)?; let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request); let request = request.into_inner(); + for profile in request + .profiles + .iter() + .filter_map(|item| item.profile.as_ref()) + { + require_gateway_assertion_profile_capability(supports_gateway_assertion, profile)?; + } let workspace = authorize_and_resolve_profile_workspace( state, &principal, @@ -3169,9 +3201,17 @@ pub(super) async fn handle_update_provider_profiles( state: &Arc, request: Request, ) -> Result, Status> { + let supports_gateway_assertion = supports_gateway_assertion_profiles(&request); let principal = super::extract_principal(&request)?; let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request); let request = request.into_inner(); + if let Some(profile) = request + .profile + .as_ref() + .and_then(|item| item.profile.as_ref()) + { + require_gateway_assertion_profile_capability(supports_gateway_assertion, profile)?; + } let workspace = authorize_and_resolve_profile_workspace( state, &principal, @@ -3254,6 +3294,25 @@ pub(super) async fn handle_update_provider_profiles( .await .map_err(|e| Status::internal(format!("fetch provider profile failed: {e}")))? .ok_or_else(|| Status::not_found("provider profile not found"))?; + if let Some(current) = stored.profile.as_ref() { + require_gateway_assertion_profile_capability(supports_gateway_assertion, current)?; + for credential in ¤t.credentials { + if credential.token_grant.as_ref().is_some_and(|grant| { + grant.grant_type + == ProviderCredentialTokenGrantType::OpenshellClientCredentials as i32 + }) && profile.credentials.iter().any(|next| { + next.name == credential.name + && next.token_grant.as_ref().is_some_and(|grant| { + grant.assertion_source + != openshell_providers::TokenGrantAssertionSource::OpenshellGateway + }) + }) { + return Err(Status::failed_precondition( + "remove the gateway assertion credential before changing its assertion source", + )); + } + } + } let current_version = stored .metadata .as_ref() @@ -3301,6 +3360,25 @@ pub(super) async fn handle_update_provider_profiles( })) } +fn supports_gateway_assertion_profiles(request: &Request) -> bool { + request + .metadata() + .get(openshell_providers::GATEWAY_ASSERTION_PROFILE_CAPABILITY) + .is_some_and(|value| value == "1") +} + +fn require_gateway_assertion_profile_capability( + supported: bool, + profile: &ProviderProfile, +) -> Result<(), Status> { + if !supported && openshell_providers::profile_uses_gateway_assertion(profile) { + return Err(Status::failed_precondition( + "profile client does not support assertion_source openshell_gateway", + )); + } + Ok(()) +} + pub(super) async fn handle_lint_provider_profiles( state: &Arc, request: Request, @@ -3331,6 +3409,7 @@ pub(super) async fn handle_lint_provider_profiles( diagnostics: diagnostics.into_iter().map(proto_diagnostic).collect(), valid, supports_stable_placeholder: true, + supports_openshell_gateway_assertion_source: true, })) } @@ -3773,6 +3852,12 @@ fn profiles_from_import_items( }); } } + if let Some(diagnostic) = + openshell_providers::unknown_token_grant_type_diagnostic(&source, profile) + { + diagnostics.push(diagnostic); + continue; + } profiles.push((source, ProviderTypeProfile::from_proto(profile))); } (profiles, diagnostics) @@ -4394,6 +4479,120 @@ fn token_grant_dependency_status(status: Status) -> Status { TokenGrantFailure::new(kind, TokenGrantStage::GatewayRpc).to_status_with_code(status.code()) } +async fn attached_provider_profile( + state: &ServerState, + sandbox_id: &str, + provider_name: &str, +) -> Result<(Provider, ProviderProfile), Status> { + let sandbox = state + .store + .get_message::(sandbox_id) + .await + .map_err(|_| token_grant_store_status())? + .ok_or_else(|| token_grant_configuration_status(tonic::Code::NotFound))?; + let workspace = sandbox.object_workspace().to_string(); + let spec = sandbox + .spec + .as_ref() + .ok_or_else(|| token_grant_configuration_status(tonic::Code::Internal))?; + if !spec + .providers + .iter() + .any(|provider| provider == provider_name) + { + return Err(token_grant_configuration_status( + tonic::Code::PermissionDenied, + )); + } + + let provider = state + .store + .get_message_by_name::(&workspace, provider_name) + .await + .map_err(|_| token_grant_store_status())? + .ok_or_else(|| token_grant_configuration_status(tonic::Code::NotFound))?; + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), &workspace) + .await + .map_err(token_grant_dependency_status)?; + let profile = get_provider_type_profile_for_scope( + &catalog, + &provider.r#type, + &provider.profile_workspace, + ) + .ok_or_else(|| token_grant_configuration_status(tonic::Code::FailedPrecondition))?; + Ok((provider, profile.to_proto())) +} + +pub(super) async fn handle_mint_provider_client_assertion( + state: &ServerState, + request: Request, +) -> Result, Status> { + let req = request.get_ref(); + let denied = || token_grant_configuration_status(tonic::Code::PermissionDenied); + let Some(Principal::Sandbox(principal)) = request.extensions().get::() else { + return Err(denied()); + }; + if req.expected_provider_id.is_empty() + || principal.sandbox_id != req.sandbox_id + || principal.auth_epoch().is_none() + { + return Err(denied()); + } + if !state + .provider_assertion_mint_limiter + .try_acquire(&principal.sandbox_id) + { + return Err(TokenGrantFailure::new( + TokenGrantFailureKind::Unavailable, + TokenGrantStage::GatewayRpc, + ) + .to_status_with_code(tonic::Code::ResourceExhausted)); + } + let (provider, profile) = + attached_provider_profile(state, &req.sandbox_id, &req.provider).await?; + let grant = profile + .credentials + .iter() + .find(|credential| credential.name == req.credential_key) + .and_then(|credential| credential.token_grant.as_ref()) + .ok_or_else(denied)?; + if provider.object_id() != req.expected_provider_id + || grant.grant_type != ProviderCredentialTokenGrantType::OpenshellClientCredentials as i32 + || grant.token_endpoint != req.token_endpoint + || !grant.jwt_svid_audience.is_empty() + || !grant.client_assertion_type.is_empty() + || grant.subject_token.is_some() + || !grant.requested_token_type.is_empty() + || !state.provider_assertions.iter().any(|registration| { + registration.provider_id == provider.object_id() + && registration.credential_key == req.credential_key + && registration.token_endpoint == grant.token_endpoint + }) + { + return Err(denied()); + } + let issuer = state + .extension_jwt_issuer + .as_ref() + .ok_or_else(|| token_grant_configuration_status(tonic::Code::FailedPrecondition))?; + let client_assertion = issuer + .mint_provider_client_assertion( + &principal.sandbox_id, + provider.object_id(), + &req.credential_key, + &grant.token_endpoint, + ) + .map_err(sanitize_token_grant_status)?; + Ok(Response::new( + openshell_core::proto::MintProviderClientAssertionResponse { + client_assertion, + bound_provider_id: provider.object_id().to_owned(), + }, + )) +} + async fn exchange_provider_subject_token_inner( state: &Arc, request: Request, @@ -4431,45 +4630,8 @@ async fn exchange_provider_subject_token_inner( )); } - let sandbox = state - .store - .get_message::(&req.sandbox_id) - .await - .map_err(|_| token_grant_store_status())? - .ok_or_else(|| token_grant_configuration_status(tonic::Code::NotFound))?; - let workspace = sandbox.object_workspace().to_string(); - let spec = sandbox - .spec - .as_ref() - .ok_or_else(|| token_grant_configuration_status(tonic::Code::Internal))?; - if !spec - .providers - .iter() - .any(|provider| provider == &req.provider) - { - return Err(token_grant_configuration_status( - tonic::Code::PermissionDenied, - )); - } - - let provider = state - .store - .get_message_by_name::(&workspace, &req.provider) - .await - .map_err(|_| token_grant_store_status())? - .ok_or_else(|| token_grant_configuration_status(tonic::Code::NotFound))?; - let catalog = state - .provider_profile_sources - .snapshot_catalog(state.store.as_ref(), &workspace) - .await - .map_err(token_grant_dependency_status)?; - let profile = get_provider_type_profile_for_scope( - &catalog, - &provider.r#type, - &provider.profile_workspace, - ) - .ok_or_else(|| token_grant_configuration_status(tonic::Code::FailedPrecondition))?; - let profile_proto = profile.to_proto(); + let (provider, profile_proto) = + attached_provider_profile(state, &req.sandbox_id, &req.provider).await?; let credential = profile_proto .credentials .iter() @@ -5721,6 +5883,186 @@ mod tests { } } + #[tokio::test] + async fn token_grant_assertion_requires_supervisor_attachment_and_exact_registration() { + use crate::auth::sandbox_jwt::ExtensionJwtIssuer; + use crate::config_file::ProviderAssertionRegistration; + use jsonwebtoken::{Algorithm, DecodingKey, Validation}; + use openshell_core::jwt::CredentialEpoch; + use openshell_core::proto::MintProviderClientAssertionRequest; + + let mut state = test_server_state_without_provider_profiles().await; + let state = Arc::get_mut(&mut state).unwrap(); + let key = openshell_bootstrap::jwt::generate_jwt_key().unwrap(); + state.extension_jwt_issuer = Some(Arc::new( + ExtensionJwtIssuer::from_pem( + key.signing_key_pem.as_bytes(), + key.public_key_pem.as_bytes(), + key.kid.clone(), + "demo", + std::time::Duration::from_secs(300), + ) + .unwrap(), + )); + let profile = provider_profile_import_item_with_grant( + "signed-grant", + ProviderCredentialTokenGrantType::OpenshellClientCredentials as i32, + ) + .profile + .unwrap(); + let endpoint = "https://issuer.example/token"; + state + .store + .put_message(&crate::provider_profile_sources::stored_provider_profile( + profile, + )) + .await + .unwrap(); + let mut provider = provider_with_values("attached", "signed-grant"); + provider.metadata.as_mut().unwrap().id = "provider-id".into(); + provider.profile_workspace.clear(); + state.store.put_message(&provider).await.unwrap(); + state + .store + .put_message(&sandbox_in_default_workspace( + "sandbox-id", + vec!["attached".into()], + )) + .await + .unwrap(); + let request = |caller: &str, provider: &str, credential: &str, url: &str| { + let mut req = Request::new(MintProviderClientAssertionRequest { + sandbox_id: "sandbox-id".into(), + provider: provider.into(), + credential_key: credential.into(), + token_endpoint: url.into(), + expected_provider_id: "provider-id".into(), + }); + req.extensions_mut() + .insert(Principal::Sandbox(SandboxPrincipal { + sandbox_id: caller.into(), + trust_domain: None, + source: SandboxIdentitySource::BootstrapJwt { + issuer: "openshell-gateway:demo".into(), + auth_epoch: Some(CredentialEpoch::new(1).unwrap()), + }, + })); + req + }; + let good = || request("sandbox-id", "attached", "access_token", endpoint); + let expect_denied = async |state: &ServerState, request| { + let err = handle_mint_provider_client_assertion(state, request) + .await + .unwrap_err(); + assert_generic(&err, Code::PermissionDenied); + }; + expect_denied(state, good()).await; + state + .provider_assertions + .push(ProviderAssertionRegistration { + provider_id: "provider-id".into(), + credential_key: "access_token".into(), + token_endpoint: endpoint.into(), + }); + let response = handle_mint_provider_client_assertion(state, good()) + .await + .unwrap() + .into_inner(); + assert_eq!(response.bound_provider_id, "provider-id"); + let assertion = response.client_assertion; + let public = DecodingKey::from_ed_pem(key.public_key_pem.as_bytes()).unwrap(); + let mut validation = Validation::new(Algorithm::EdDSA); + validation.set_audience(&[endpoint]); + validation.set_issuer(&["openshell-gateway:demo"]); + let verified = openshell_crypto::jwt::decode::( + &assertion, + &public, + &validation, + ) + .unwrap(); + assert_eq!( + verified.header.typ.as_deref(), + Some("openshell-provider-assertion+jwt") + ); + assert_eq!(verified.header.kid.as_deref(), Some(key.kid.as_str())); + let claims = verified.claims; + assert_eq!(claims["sub"], "openshell-sandbox:sandbox-id"); + assert_eq!(claims["sandbox_id"], "sandbox-id"); + assert_eq!(claims["provider_id"], "provider-id"); + assert_eq!(claims["credential_key"], "access_token"); + assert_eq!(claims["caller_kind"], "supervisor"); + assert_eq!( + claims["exp"].as_i64().unwrap() - claims["iat"].as_i64().unwrap(), + 60 + ); + assert!(uuid::Uuid::parse_str(claims["jti"].as_str().unwrap()).is_ok()); + validation.set_audience(&["openshell-gateway:demo"]); + assert!( + openshell_crypto::jwt::decode::( + &assertion, + &public, + &validation + ) + .is_err() + ); + for invalid in [ + request("sibling", "attached", "access_token", endpoint), + request("sandbox-id", "detached", "access_token", endpoint), + request("sandbox-id", "attached", "wrong", endpoint), + request( + "sandbox-id", + "attached", + "access_token", + "https://issuer.example/other", + ), + ] { + expect_denied(state, invalid).await; + } + state.provider_assertions[0].provider_id = "different-instance".into(); + expect_denied(state, good()).await; + state + .store + .delete(Provider::object_type(), "provider-id") + .await + .unwrap(); + provider.metadata.as_mut().unwrap().id = "different-instance".into(); + state.store.put_message(&provider).await.unwrap(); + assert_eq!( + handle_mint_provider_client_assertion(state, good()) + .await + .unwrap_err() + .code(), + Code::PermissionDenied, + "a registered replacement must not satisfy the installed provider identity" + ); + let mut replacement = good(); + replacement.get_mut().expected_provider_id = "different-instance".into(); + let response = handle_mint_provider_client_assertion(state, replacement) + .await + .unwrap() + .into_inner(); + assert_eq!(response.bound_provider_id, "different-instance"); + state.provider_assertion_mint_limiter = + crate::auth::extension_mint_limit::ExtensionMintLimiter::new( + std::time::Duration::from_secs(60), + 1, + ); + assert!( + state + .provider_assertion_mint_limiter + .try_acquire("sandbox-id") + ); + assert_eq!( + handle_mint_provider_client_assertion(state, good()) + .await + .unwrap_err() + .code(), + Code::ResourceExhausted, + "the budget must apply before even looking up the stale provider" + ); + assert!(state.extension_mint_limiter.try_acquire("sandbox-id")); + } + fn assert_generic(status: &Status, code: Code) -> TokenGrantFailure { assert_eq!(status.code(), code); assert_eq!(status.message(), "dynamic token grant failed"); @@ -6119,6 +6461,7 @@ mod tests { refresh: None, path_template: String::new(), token_grant_owners: vec!["untrusted-profile-owner".to_string()], + token_grant_provider_id: "untrusted-profile-provider".to_string(), token_grant: Some(ProviderCredentialTokenGrant { grant_type: ProviderCredentialTokenGrantType::ClientCredentials as i32, token_endpoint: "http://keycloak.default.svc.cluster.local/realms/openshell/protocol/openid-connect/token".to_string(), @@ -6130,6 +6473,7 @@ mod tests { subject_token: None, scopes: vec!["openid".to_string()], requested_token_type: String::new(), + workload_credential_handle: String::new(), cache_ttl: Some(prost_types::Duration { seconds: 300, nanos: 0 }), audience_overrides: service_audiences .iter() @@ -6186,6 +6530,7 @@ mod tests { .unwrap(); assert_eq!(credential.token_grant_owners.len(), 1); assert_ne!(credential.token_grant_owners[0], "untrusted-profile-owner"); + assert_eq!(credential.token_grant_provider_id, "provider-uid"); assert!(key.contains(&credential.token_grant_owners[0])); let grant = credential.token_grant.as_ref().unwrap(); assert_eq!(grant.audience, audience); @@ -6335,24 +6680,11 @@ mod tests { path: &str, credentials: Vec, ) { - let mut profile = custom_profile(id); - profile.credentials = credentials; - profile.endpoints = vec![NetworkEndpoint { - host: host.to_string(), - port, - path: path.to_string(), - protocol: "rest".to_string(), - access: openshell_core::proto::NetworkAccessPreset::Full as i32, - ..Default::default() - }]; handle_import_provider_profiles( state, authed_request(ImportProviderProfilesRequest { request_id: String::new(), - profiles: vec![ProviderProfileImportItem { - profile: Some(profile), - source: format!("{id}.yaml"), - }], + profiles: vec![token_grant_import_item(id, host, port, path, credentials)], workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -6362,6 +6694,29 @@ mod tests { .unwrap(); } + fn token_grant_import_item( + id: &str, + host: &str, + port: u32, + path: &str, + credentials: Vec, + ) -> ProviderProfileImportItem { + let mut profile = custom_profile(id); + profile.credentials = credentials; + profile.endpoints = vec![NetworkEndpoint { + host: host.to_string(), + port, + path: path.to_string(), + protocol: "rest".to_string(), + access: openshell_core::proto::NetworkAccessPreset::Full as i32, + ..Default::default() + }]; + ProviderProfileImportItem { + profile: Some(profile), + source: format!("{id}.yaml"), + } + } + async fn create_empty_token_grant_provider( store: &Store, name: &str, @@ -7259,6 +7614,7 @@ mod tests { fn refreshable_credential(name: &str, env_var: &str) -> ProviderProfileCredential { ProviderProfileCredential { token_grant_owners: Vec::new(), + token_grant_provider_id: String::new(), name: name.to_string(), description: String::new(), env_vars: vec![env_var.to_string()], @@ -7330,6 +7686,7 @@ mod tests { fn static_credential(name: &str, env_var: &str, required: bool) -> ProviderProfileCredential { ProviderProfileCredential { token_grant_owners: Vec::new(), + token_grant_provider_id: String::new(), name: name.to_string(), description: String::new(), env_vars: vec![env_var.to_string()], @@ -7347,6 +7704,7 @@ mod tests { fn token_grant_credential(name: &str) -> ProviderProfileCredential { ProviderProfileCredential { token_grant_owners: Vec::new(), + token_grant_provider_id: String::new(), name: name.to_string(), description: String::new(), env_vars: Vec::new(), @@ -7367,6 +7725,7 @@ mod tests { subject_token: None, scopes: vec!["read".to_string()], requested_token_type: String::new(), + workload_credential_handle: String::new(), cache_ttl: Some(prost_types::Duration { seconds: 300, nanos: 0, @@ -7710,6 +8069,151 @@ mod tests { })); } + fn provider_profile_import_item_with_grant( + id: &str, + grant_type: i32, + ) -> ProviderProfileImportItem { + let mut grant = ProviderCredentialTokenGrant { + grant_type, + token_endpoint: "https://issuer.example/token".into(), + ..Default::default() + }; + let mut credentials = Vec::new(); + if grant_type == ProviderCredentialTokenGrantType::TokenExchange as i32 { + grant.subject_token = Some(ProviderCredentialTokenGrantSubjectToken { + source: "provider_credential".into(), + credential: "subject_token".into(), + ..Default::default() + }); + credentials.push(ProviderProfileCredential { + name: "subject_token".into(), + ..Default::default() + }); + } + credentials.insert( + 0, + ProviderProfileCredential { + name: "access_token".into(), + token_grant: Some(grant), + ..Default::default() + }, + ); + token_grant_import_item(id, "api.example.com", 443, "", credentials) + } + + #[tokio::test] + async fn import_provider_profiles_preserves_known_grant_types_and_legacy_default() { + use openshell_core::proto::ProviderCredentialTokenGrantType::{ + ClientCredentials, OpenshellClientCredentials, TokenExchange, Unspecified, + }; + use openshell_core::proto::open_shell_server::OpenShell; + + fn capable_request(inner: T) -> Request { + let mut request = authed_request(inner); + request.metadata_mut().insert( + openshell_providers::GATEWAY_ASSERTION_PROFILE_CAPABILITY, + tonic::metadata::MetadataValue::from_static("1"), + ); + request + } + + let service = crate::grpc::OpenShellService::new(test_server_state().await); + let cases = [ + ("grant-legacy", Unspecified, ClientCredentials), + ("grant-client", ClientCredentials, ClientCredentials), + ("grant-exchange", TokenExchange, TokenExchange), + ( + "grant-openshell", + OpenshellClientCredentials, + OpenshellClientCredentials, + ), + ]; + let import = ImportProviderProfilesRequest { + profiles: cases + .iter() + .map(|(id, input, _)| provider_profile_import_item_with_grant(id, *input as i32)) + .collect(), + ..Default::default() + }; + let denied = service + .import_provider_profiles(authed_request(import.clone())) + .await + .unwrap_err(); + assert_eq!(denied.code(), Code::FailedPrecondition); + let get = |id: &str| GetProviderProfileRequest { + id: id.into(), + ..Default::default() + }; + for (id, _, _) in cases { + let absent = service + .get_provider_profile(capable_request(get(id))) + .await + .unwrap_err(); + assert_eq!(absent.code(), Code::NotFound); + } + let response = service + .import_provider_profiles(capable_request(import)) + .await + .unwrap() + .into_inner(); + + assert!(response.imported, "{response:?}"); + assert_eq!(response.profiles.len(), cases.len()); + assert_eq!( + service + .get_provider_profile(authed_request(get("grant-openshell"))) + .await + .unwrap_err() + .code(), + Code::FailedPrecondition, + ); + let mut stripped = response + .profiles + .iter() + .find(|profile| profile.id == "grant-openshell") + .unwrap() + .clone(); + stripped.credentials[0] + .token_grant + .as_mut() + .unwrap() + .grant_type = ClientCredentials as i32; + assert_eq!( + service + .update_provider_profiles(capable_request(UpdateProviderProfilesRequest { + id: stripped.id.clone(), + expected_resource_version: stripped.resource_version, + profile: Some(ProviderProfileImportItem { + profile: Some(stripped), + source: "stripped.yaml".into(), + }), + ..Default::default() + })) + .await + .unwrap_err() + .code(), + Code::FailedPrecondition + ); + for (id, _, expected) in cases { + let profile = service + .get_provider_profile(capable_request(get(id))) + .await + .unwrap() + .into_inner() + .profile + .unwrap(); + assert_eq!( + profile.credentials[0] + .token_grant + .as_ref() + .unwrap() + .grant_type, + expected as i32, + "{id}" + ); + } + } + #[tokio::test] async fn profile_update_rejects_fanout_endpoint_ambiguity_without_persisting() { let state = test_server_state().await; @@ -8003,55 +8507,60 @@ mod tests { #[tokio::test] async fn import_provider_profiles_rejects_mixed_batch_without_partial_import() { - let state = test_server_state().await; - let response = handle_import_provider_profiles( - &state, - authed_request(ImportProviderProfilesRequest { - request_id: String::new(), - profiles: vec![ - ProviderProfileImportItem { - profile: Some(custom_profile("bulk-one")), - source: "bulk-one.yaml".to_string(), - }, - ProviderProfileImportItem { - profile: Some(custom_profile_with_invalid_endpoint("bulk-bad")), - source: "bulk-bad.yaml".to_string(), - }, - ProviderProfileImportItem { - profile: Some(custom_profile("bulk-two")), - source: "bulk-two.yaml".to_string(), - }, - ], - workspace_scope: Some(openshell_core::proto::workspace_selector( - "default".to_string(), - )), - }), - ) - .await - .unwrap() - .into_inner(); + use openshell_core::proto::open_shell_server::OpenShell; - assert!(!response.imported); - assert!(response.profiles.is_empty()); - assert!(response.diagnostics.iter().any(|diagnostic| { - diagnostic.profile_id == "bulk-bad" - && diagnostic.field == "endpoints[0]" - && diagnostic.message.contains("invalid endpoint") - })); - - for id in ["bulk-one", "bulk-two"] { - let missing = handle_get_provider_profile( - &state, - authed_request(GetProviderProfileRequest { - id: id.to_string(), - workspace_scope: Some(openshell_core::proto::workspace_selector( - "default".to_string(), - )), - }), - ) - .await - .unwrap_err(); - assert_eq!(missing.code(), Code::NotFound); + let service = crate::grpc::OpenShellService::new(test_server_state().await); + let workspace = Some(openshell_core::proto::workspace_selector("default")); + let ordinary = |profile: ProviderProfile| ProviderProfileImportItem { + source: format!("{}.yaml", profile.id), + profile: Some(profile), + }; + let rejected_batch = async |profiles: Vec| { + let response = service + .import_provider_profiles(authed_request(ImportProviderProfilesRequest { + profiles: profiles.clone(), + workspace_scope: workspace.clone(), + ..Default::default() + })) + .await + .unwrap() + .into_inner(); + assert!(!response.imported); + assert!(response.profiles.is_empty()); + for item in profiles { + let missing = service + .get_provider_profile(authed_request(GetProviderProfileRequest { + id: item.profile.unwrap().id, + workspace_scope: workspace.clone(), + })) + .await + .unwrap_err(); + assert_eq!(missing.code(), Code::NotFound); + } + response.diagnostics + }; + let diagnostics = rejected_batch(vec![ + ordinary(custom_profile("bulk-one")), + ordinary(custom_profile_with_invalid_endpoint("bulk-bad")), + ordinary(custom_profile("bulk-two")), + ]) + .await; + assert!(diagnostics.iter().any(|d| d.profile_id == "bulk-bad" + && d.field == "endpoints[0]" + && d.message.contains("invalid endpoint"))); + + let diagnostics = rejected_batch(vec![ + provider_profile_import_item_with_grant("grant-legacy", 0), + provider_profile_import_item_with_grant("grant-negative", -1), + provider_profile_import_item_with_grant("grant-unknown", i32::MAX), + ]) + .await; + assert_eq!(diagnostics.len(), 2); + for id in ["grant-negative", "grant-unknown"] { + assert!(diagnostics.iter().any(|d| d.profile_id == id + && d.source == format!("{id}.yaml") + && d.field == "credentials[0].token_grant.grant_type" + && d.severity == "error")); } } @@ -8158,6 +8667,7 @@ mod tests { profile: Some(custom_profile("lint-two")), source: "lint-two.yaml".to_string(), }, + provider_profile_import_item_with_grant("lint-unknown", i32::MAX), ], workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -8170,6 +8680,14 @@ mod tests { assert!(!response.valid); assert!(response.supports_stable_placeholder); + assert!(response.supports_openshell_gateway_assertion_source); + assert!( + response + .diagnostics + .iter() + .any(|d| d.profile_id == "lint-unknown" + && d.field == "credentials[0].token_grant.grant_type") + ); assert!(response.diagnostics.iter().any(|diagnostic| { diagnostic.profile_id == "lint-bad" && diagnostic.field == "endpoints[0]" @@ -11716,6 +12234,7 @@ mod tests { description: String::new(), env_vars: vec!["DELEGATED_ACCESS_TOKEN".to_string()], token_grant_owners: Vec::new(), + token_grant_provider_id: String::new(), required: true, stable_placeholder: false, auth_style: "bearer".to_string(), diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 8ecfbb0b99..36b7c9fdca 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -403,7 +403,11 @@ pub struct ServerState { /// OIDC JWKS cache for JWT validation. `None` when OIDC is not configured. pub oidc_cache: Option>, - /// Typed extension JWT issuer and public verification metadata. + /// Operator registrations for outbound provider client assertions. + pub(crate) provider_assertions: Vec, + /// Separate per-sandbox budget for provider assertions before catalog lookup. + pub(crate) provider_assertion_mint_limiter: auth::extension_mint_limit::ExtensionMintLimiter, + /// Signing authority and public verification metadata for outbound JWTs. pub extension_jwt_issuer: Option>, /// Launch-scoped gateway and Sandbox Protocol token authority. @@ -673,6 +677,11 @@ impl ServerState { extension_mint_limiter: auth::extension_mint_limit::ExtensionMintLimiter::default(), middleware_registry: Arc::new(MiddlewareRegistry::default()), oidc_cache, + provider_assertions: Vec::new(), + provider_assertion_mint_limiter: auth::extension_mint_limit::ExtensionMintLimiter::new( + Duration::from_mins(1), + 60, + ), extension_jwt_issuer: None, sandbox_session_jwt_authority: None, compute_driver_authenticator: None, @@ -964,6 +973,11 @@ pub(crate) async fn run_server( state.middleware_registry = middleware_registry; state.gateway_interceptors = gateway_interceptors; state.provider_profile_sources = provider_profile_sources; + if let Some(file) = config_file.as_ref() { + state + .provider_assertions + .clone_from(&file.openshell.gateway.provider_assertions); + } state.extension_jwt_issuer = extension_jwt_issuer.clone(); state.sandbox_session_jwt_authority = sandbox_session_jwt_authority; if let Some(issuer) = extension_jwt_issuer { diff --git a/crates/openshell-server/src/provider_profile_sources.rs b/crates/openshell-server/src/provider_profile_sources.rs index 77230e5cce..c490021911 100644 --- a/crates/openshell-server/src/provider_profile_sources.rs +++ b/crates/openshell-server/src/provider_profile_sources.rs @@ -19,7 +19,8 @@ use openshell_gateway_interceptors::{ ProviderProfileSourceSnapshot as InterceptorProfileSnapshot, }; use openshell_providers::{ - ProfileValidationDiagnostic, ProviderTypeProfile, normalize_profile_id, validate_profile_set, + ProfileValidationDiagnostic, ProviderTypeProfile, normalize_profile_id, + unknown_token_grant_type_diagnostic, validate_profile_set, }; use prost::Message as _; use sha2::{Digest, Sha256}; @@ -760,6 +761,13 @@ fn build_effective_profiles( "provider profile source '{source_id}' returned no profiles" ))); } + if let Some(diagnostic) = snapshot + .profiles + .iter() + .find_map(|scoped| unknown_token_grant_type_diagnostic(source_id, &scoped.profile)) + { + return Err(invalid_source_profile_status(source_id, diagnostic)); + } catalog_hasher.update((source_id.len() as u64).to_le_bytes()); catalog_hasher.update(source_id.as_bytes()); @@ -900,14 +908,21 @@ fn validate_source_profiles( .into_iter() .find(|diagnostic| diagnostic.severity == "error") { - return Err(Status::failed_precondition(format!( - "provider profile source '{source_id}' is invalid: {}", - format_diagnostic(diagnostic) - ))); + return Err(invalid_source_profile_status(source_id, diagnostic)); } Ok(()) } +fn invalid_source_profile_status( + source_id: &str, + diagnostic: ProfileValidationDiagnostic, +) -> Status { + Status::failed_precondition(format!( + "provider profile source '{source_id}' is invalid: {}", + format_diagnostic(diagnostic) + )) +} + fn format_diagnostic(diagnostic: ProfileValidationDiagnostic) -> String { if diagnostic.profile_id.is_empty() { format!("{}: {}", diagnostic.field, diagnostic.message) @@ -1605,6 +1620,47 @@ mod tests { ); } + #[test] + fn catalog_rejects_unknown_raw_token_grant_types() { + use openshell_core::proto::{ProviderCredentialTokenGrant, ProviderProfileCredential}; + + for (source_id, scope) in [ + ("interceptor/test", ProfileScope::Static), + ("user", ProfileScope::Platform), + ("user", ProfileScope::Workspace), + ] { + let catalog = |grant_type| { + let mut entry = scoped(scope, "grant-profile"); + entry.profile.credentials.push(ProviderProfileCredential { + name: "access_token".into(), + token_grant: Some(ProviderCredentialTokenGrant { + grant_type, + token_endpoint: "https://issuer.example/token".into(), + ..Default::default() + }), + ..Default::default() + }); + build_effective_profiles(vec![CollectedProviderProfileSnapshot { + source_id: source_id.into(), + revision: "revision".into(), + profiles: vec![entry], + user_managed: scope != ProfileScope::Static, + allow_empty: false, + }]) + }; + + catalog(0).expect("legacy unspecified grant must remain valid"); + let error = catalog(i32::MAX).expect_err("unknown grant must fail before conversion"); + assert_eq!(error.code(), tonic::Code::FailedPrecondition); + assert_eq!( + error.message(), + format!( + "provider profile source '{source_id}' is invalid: provider profile 'grant-profile' credentials[1].token_grant.grant_type: unknown provider token grant type" + ), + ); + } + } + #[tokio::test] async fn interceptor_snapshot_passes_through_adapter_and_validation_boundary() { let (runtime, task) = interceptor_runtime( diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index d8da420871..6cfd0f46fa 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -177,10 +177,20 @@ mod tests { // selections absent; storage.v1 remains frozen. Public field 3 retains its // image wire identity, now with explicit presence. Compute configuration // updates use a separate capability/service and add one classified RPC. + // The provider assertion RPC adds two public messages; the grant type's + // new enum value is also visible in persisted provider profiles. + // ProviderProfileCredential.token_grant_provider_id is additive in both + // closures. Prior profiles decode an empty value, and the gateway derives + // it from the provider record only when delivering a credential snapshot. + // Lint capability and mint request/response binding fields are public-only. + // ProviderCredentialTokenGrant field 11 adds a gateway-derived workload + // handle to both descriptor closures. An older grant decodes an empty + // handle. Authored profiles strip the field before persistence, and the + // gateway supplies it only in a resolved sandbox environment snapshot. const PUBLIC_RPC_SCHEMA_SHA256: &str = - "3b4c2af6cf22e7ed1d84279ff865acb0db7199717bee9bc041caef791334104d"; + "7bf554e62f9a82602a1c246c89e711d7d0a53b481a6ab32b87aa520b69f0714a"; const DURABLE_SCHEMA_SHA256: &str = - "cb15f8f79a855064a43fe8c6cd908bf07eca6604e2bf8ab7d2de0ef33e97da4e"; + "db345aa85fe225ee7d3f391f28a033cc0adfc11b3a882d0a4c34ccba1afe8acf"; const PUBLIC_DURABLE_OVERLAP_SHA256: &str = "c0c3ea17f105aaa6d843c466fd4240451c2105dce7675384935d4aaecdac8abf"; // Encoded with the schema that omitted stable_placeholder. Keep these @@ -652,7 +662,7 @@ mod tests { } assert_eq!( compiled_method_count, - 109 + PROVIDER_READINESS_RPC_SIGNATURES.len() + 110 + PROVIDER_READINESS_RPC_SIGNATURES.len() + PEER_OWNER_RPC_SIGNATURES.len() + PEER_CONFIG_RPC_SIGNATURES.len() + isolation_methods.len() @@ -662,7 +672,7 @@ mod tests { ); assert_eq!( methods.len(), - 81 + PROVIDER_READINESS_RPC_SIGNATURES.len() + 82 + PROVIDER_READINESS_RPC_SIGNATURES.len() + PEER_OWNER_RPC_SIGNATURES.len() + PEER_CONFIG_RPC_SIGNATURES.len(), "inventory every public gateway RPC" @@ -672,7 +682,7 @@ mod tests { .iter() .filter(|method| method.starts_with("openshell.v1.OpenShell/")) .count(), - 80 + PROVIDER_READINESS_RPC_SIGNATURES.len() + 81 + PROVIDER_READINESS_RPC_SIGNATURES.len() + PEER_OWNER_RPC_SIGNATURES.len() + PEER_CONFIG_RPC_SIGNATURES.len() ); @@ -723,7 +733,7 @@ mod tests { overlap_hash.as_str(), ), ( - (337, 31), + (339, 31), (100, 22), (86, 22), PUBLIC_RPC_SCHEMA_SHA256, @@ -808,6 +818,27 @@ mod tests { assert!(credential.required); assert_eq!(credential.auth_style, "bearer"); assert!(!credential.stable_placeholder); + assert!(credential.token_grant_provider_id.is_empty()); + } + + #[test] + fn legacy_token_grant_decodes_without_a_workload_handle() { + use openshell_core::proto::{ + ProviderCredentialTokenGrant, ProviderCredentialTokenGrantType, + }; + + // The previous wire shape has the issuer URL (field 1) and the client + // credentials grant type (field 8). + let bytes = + legacy_bytes("0a1c68747470733a2f2f6973737565722e6578616d706c652f746f6b656e4001"); + let grant = ProviderCredentialTokenGrant::decode(bytes.as_slice()).unwrap(); + assert_eq!(grant.token_endpoint, "https://issuer.example/token"); + assert_eq!( + grant.grant_type, + ProviderCredentialTokenGrantType::ClientCredentials as i32 + ); + assert!(grant.workload_credential_handle.is_empty()); + assert_eq!(grant.encode_to_vec(), bytes); } #[test] diff --git a/crates/openshell-server/src/test_support.rs b/crates/openshell-server/src/test_support.rs index b1d5736a93..f7814edc86 100644 --- a/crates/openshell-server/src/test_support.rs +++ b/crates/openshell-server/src/test_support.rs @@ -40,6 +40,8 @@ use tokio::net::{UnixListener, UnixStream}; use tokio::task::JoinHandle; use tonic::{Request, Response, Status}; +pub mod provider_grant; + type WatchStream = Pin> + Send>>; /// Build a real in-memory gateway service backed by the requested test compute driver. @@ -47,6 +49,10 @@ type WatchStream = Pin /// This fixture is intentionally narrow: integration tests can exercise the public /// gRPC service without exposing the gateway's internal state construction details. pub async fn gateway_service_with_driver(driver_name: &str) -> crate::OpenShellService { + crate::OpenShellService::new(Arc::new(gateway_state_with_driver(driver_name).await)) +} + +async fn gateway_state_with_driver(driver_name: &str) -> ServerState { let store = Arc::new( Store::connect("sqlite::memory:?cache=shared") .await @@ -60,7 +66,7 @@ pub async fn gateway_service_with_driver(driver_name: &str) -> crate::OpenShellS } else { new_test_runtime_for_driver(store.clone(), driver_name).await }; - let state = Arc::new(ServerState::new( + ServerState::new( Config::new(None) .with_database_url("sqlite::memory:?cache=shared") .with_credential_drivers(["test-static"]), @@ -71,8 +77,7 @@ pub async fn gateway_service_with_driver(driver_name: &str) -> crate::OpenShellS TracingLogBus::new(), Arc::new(SupervisorSessionRegistry::new()), None, - )); - crate::OpenShellService::new(state) + ) } /// Tonic interceptor that authenticates integration-test requests as the dev user. diff --git a/crates/openshell-server/src/test_support/provider_grant.rs b/crates/openshell-server/src/test_support/provider_grant.rs new file mode 100644 index 0000000000..83bb84ab16 --- /dev/null +++ b/crates/openshell-server/src/test_support/provider_grant.rs @@ -0,0 +1,226 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! In-memory gateway with real launch credentials for the supervisor wire test. + +use std::sync::Arc; +use std::time::Duration; + +use openshell_core::grpc_client::{GatewayCredentials, GatewayEndpoint}; +use openshell_core::proto::{ + DetachSandboxProviderRequest, ImportProviderProfilesRequest, Provider, ProviderProfile, + ProviderProfileImportItem, Sandbox, SandboxPhase, SandboxPolicy, SandboxSpec, SandboxStatus, + datamodel::v1::ObjectMeta, open_shell_server::OpenShell, workspace_selector, +}; +use prost::Message; +use tokio::sync::watch; +use tokio::task::JoinHandle; + +use crate::auth::sandbox_jwt::{ExtensionJwtIssuer, SandboxSessionJwtAuthority}; +use crate::auth::sandbox_session::PersistedSandboxIdentity; +use crate::config_file::ProviderAssertionRegistration; +use crate::persistence::{ObjectType, WriteCondition}; +use crate::{MultiplexService, OpenShellService, Store, TlsAcceptor}; + +pub const SANDBOX: &str = "wire-sandbox"; +pub const PROVIDER_ID: &str = "wire-provider-id"; +pub const SIBLING: &str = "wire-sibling"; +const PROVIDER: &str = "wire-provider"; +const GATEWAY: &str = "wire-gateway"; + +pub struct ProviderGrantGateway { + pub sandbox: GatewayEndpoint, + pub sibling: GatewayEndpoint, + service: OpenShellService, + accept_loop: JoinHandle<()>, + _shutdown: watch::Sender, +} + +impl Drop for ProviderGrantGateway { + fn drop(&mut self) { + self.accept_loop.abort(); + } +} + +impl ProviderGrantGateway { + pub async fn start( + tls: TlsAcceptor, + profile: ProviderProfile, + credential_key: &str, + endpoint: &str, + environment: &[(&str, &str)], + ) -> Self { + let mut state = super::gateway_state_with_driver("test").await; + let key = openshell_bootstrap::jwt::generate_jwt_key().expect("test signing key"); + let authority = Arc::new( + SandboxSessionJwtAuthority::from_pem( + key.signing_key_pem.as_bytes(), + key.public_key_pem.as_bytes(), + key.kid.clone(), + GATEWAY, + Some(Duration::from_secs(900)), + ) + .expect("session authority"), + ); + let assertion_issuer = ExtensionJwtIssuer::from_pem( + key.signing_key_pem.as_bytes(), + key.public_key_pem.as_bytes(), + key.kid.clone(), + GATEWAY, + Duration::from_secs(300), + ) + .expect("assertion issuer"); + state.sandbox_session_jwt_authority = Some(authority.clone()); + state.extension_jwt_issuer = Some(Arc::new(assertion_issuer)); + state.provider_assertions = vec![ProviderAssertionRegistration { + provider_id: PROVIDER_ID.into(), + credential_key: credential_key.into(), + token_endpoint: endpoint.into(), + }]; + + let state = Arc::new(state); + let profile_id = profile.id.clone(); + let mut request = super::authenticate_as_dev_user(tonic::Request::new(())) + .expect("fixture user") + .map(|()| ImportProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(profile), + source: "provider-grant-wire.yaml".into(), + }], + workspace_scope: Some(workspace_selector("default")), + ..Default::default() + }); + request.metadata_mut().insert( + openshell_providers::GATEWAY_ASSERTION_PROFILE_CAPABILITY, + "1".parse().unwrap(), + ); + let imported = OpenShellService::new(state.clone()) + .import_provider_profiles(request) + .await + .expect("import test provider profile") + .into_inner(); + assert!( + imported.imported, + "fixture profile: {:?}", + imported.diagnostics + ); + + persist( + &state.store, + PROVIDER, + PROVIDER_ID, + Provider { + metadata: Some(metadata(PROVIDER, PROVIDER_ID)), + r#type: profile_id, + profile_workspace: "default".into(), + credentials: environment + .iter() + .map(|(name, value)| ((*name).into(), (*value).into())) + .collect(), + ..Default::default() + }, + ) + .await; + let sandbox_credentials = + persist_sandbox(&state.store, &authority, SANDBOX, PROVIDER).await; + let sibling_credentials = + persist_sandbox(&state.store, &authority, SIBLING, PROVIDER).await; + let listener = crate::bind_gateway_listener(([127, 0, 0, 1], 0).into()) + .await + .expect("gateway bind"); + let url = format!("https://localhost:{}", listener.address.port()); + let (shutdown, shutdown_rx) = watch::channel(false); + let accept_loop = tokio::spawn(crate::serve_gateway_listener( + listener, + MultiplexService::new(state.clone()), + Some(tls), + false, + shutdown_rx, + )); + + Self { + sandbox: GatewayEndpoint::new(url.clone(), sandbox_credentials), + sibling: GatewayEndpoint::new(url, sibling_credentials), + service: OpenShellService::new(state), + accept_loop, + _shutdown: shutdown, + } + } + + pub async fn detach(&self) { + let request = super::authenticate_as_dev_user(tonic::Request::new(())) + .expect("fixture user") + .map(|()| DetachSandboxProviderRequest { + sandbox: SANDBOX.into(), + provider: PROVIDER.into(), + workspace_scope: Some(workspace_selector("default")), + ..Default::default() + }); + assert!( + self.service + .detach_sandbox_provider(request) + .await + .expect("detach provider from the fixture sandbox") + .into_inner() + .detached + ); + } +} + +fn metadata(name: &str, id: &str) -> ObjectMeta { + ObjectMeta { + id: id.into(), + name: name.into(), + workspace: "default".into(), + ..Default::default() + } +} + +async fn persist_sandbox( + store: &Store, + authority: &SandboxSessionJwtAuthority, + sandbox_id: &str, + provider: &str, +) -> GatewayCredentials { + let identity = PersistedSandboxIdentity::new().expect("test runtime identity"); + let mut metadata = metadata(sandbox_id, sandbox_id); + identity.write(&mut metadata.annotations); + persist( + store, + sandbox_id, + sandbox_id, + Sandbox { + metadata: Some(metadata), + spec: Some(SandboxSpec { + providers: vec![provider.into()], + policy: Some(SandboxPolicy::default()), + ..Default::default() + }), + status: Some(SandboxStatus { + phase: SandboxPhase::Provisioning as i32, + ..Default::default() + }), + ..Default::default() + }, + ) + .await; + let auth = authority + .mint_persisted_launch(sandbox_id, &identity) + .expect("mint test launch"); + GatewayCredentials::from_auth_bundle(&auth.supervisor).expect("test launch credentials") +} + +async fn persist(store: &Store, name: &str, id: &str, message: T) { + store + .put_if( + T::object_type(), + id, + name, + "default", + &message.encode_to_vec(), + None, + WriteCondition::MustCreate, + ) + .await + .expect("persist test record"); +} diff --git a/crates/openshell-server/tests/common/mod.rs b/crates/openshell-server/tests/common/mod.rs index dd91664d39..6f8929c8fa 100644 --- a/crates/openshell-server/tests/common/mod.rs +++ b/crates/openshell-server/tests/common/mod.rs @@ -9,6 +9,9 @@ #![allow(dead_code)] +#[path = "../../../../tests/support/unused_gateway_rpcs.rs"] +mod unused_gateway_rpcs; + use hyper_util::{ rt::{TokioExecutor, TokioIo}, server::conn::auto::Builder, @@ -16,9 +19,8 @@ use hyper_util::{ use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - ExchangeProviderSubjectTokenRequest, ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, - ExecSandboxInput, ExecSandboxRequest, GatewayMessage, GetGatewayConfigRequest, - GetGatewayConfigResponse, GetProviderRequest, GetSandboxConfigRequest, + ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, GatewayMessage, + GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, HealthRequest, HealthResponse, IssueSandboxTokenRequest, IssueSandboxTokenResponse, ListProvidersRequest, @@ -141,33 +143,7 @@ impl OpenShell for TestOpenShell { Ok(Response::new(SandboxResponse::default())) } - async fn create_sandbox_template( - &self, - _request: tonic::Request, - ) -> Result, Status> { - Err(Status::unimplemented("unused")) - } - - async fn get_sandbox_template( - &self, - _request: tonic::Request, - ) -> Result, Status> { - Err(Status::unimplemented("unused")) - } - - async fn list_sandbox_templates( - &self, - _request: tonic::Request, - ) -> Result, Status> { - Err(Status::unimplemented("unused")) - } - - async fn delete_sandbox_template( - &self, - _request: tonic::Request, - ) -> Result, Status> { - Err(Status::unimplemented("unused")) - } + crate::unused_gateway_rpcs!(sandbox_templates); async fn stop_sandbox( &self, @@ -331,12 +307,7 @@ impl OpenShell for TestOpenShell { Ok(Response::new(RevokeSshSessionResponse::default())) } - async fn exchange_provider_subject_token( - &self, - _request: tonic::Request, - ) -> Result, Status> { - Err(Status::unimplemented("unused")) - } + crate::unused_gateway_rpcs!(provider_token_grants); async fn create_provider( &self, diff --git a/crates/openshell-server/tests/supervisor_relay_integration.rs b/crates/openshell-server/tests/supervisor_relay_integration.rs index ceb2fa6bf8..492747365a 100644 --- a/crates/openshell-server/tests/supervisor_relay_integration.rs +++ b/crates/openshell-server/tests/supervisor_relay_integration.rs @@ -15,6 +15,9 @@ //! wire layer that unit tests can't see and that are expensive to catch in //! E2E. +#[path = "../../../tests/support/unused_gateway_rpcs.rs"] +mod unused_gateway_rpcs; + use std::sync::Arc; use std::time::Duration; @@ -128,33 +131,7 @@ impl OpenShell for RelayGateway { // ------ unused stubs ------ - async fn create_sandbox_template( - &self, - _request: tonic::Request, - ) -> Result, Status> { - Err(Status::unimplemented("unused")) - } - - async fn get_sandbox_template( - &self, - _request: tonic::Request, - ) -> Result, Status> { - Err(Status::unimplemented("unused")) - } - - async fn list_sandbox_templates( - &self, - _request: tonic::Request, - ) -> Result, Status> { - Err(Status::unimplemented("unused")) - } - - async fn delete_sandbox_template( - &self, - _request: tonic::Request, - ) -> Result, Status> { - Err(Status::unimplemented("unused")) - } + unused_gateway_rpcs!(sandbox_templates); type PeerRelayStream = std::pin::Pin> + Send>>; @@ -362,12 +339,7 @@ impl OpenShell for RelayGateway { ) -> Result, Status> { Err(Status::unimplemented("unused")) } - async fn exchange_provider_subject_token( - &self, - _: tonic::Request, - ) -> Result, Status> { - Err(Status::unimplemented("unused")) - } + unused_gateway_rpcs!(provider_token_grants); async fn create_provider( &self, _: tonic::Request, diff --git a/crates/openshell-supervisor-network/Cargo.toml b/crates/openshell-supervisor-network/Cargo.toml index e51f282cd5..4cc55e4f15 100644 --- a/crates/openshell-supervisor-network/Cargo.toml +++ b/crates/openshell-supervisor-network/Cargo.toml @@ -64,6 +64,9 @@ default = ["bundled-ca-roots"] bundled-ca-roots = ["dep:webpki-roots"] [dev-dependencies] +axum = { workspace = true } +jsonwebtoken = { workspace = true } +openshell-server = { path = "../openshell-server", features = ["test-support"] } openshell-sandbox-backend = { path = "../openshell-sandbox-backend", features = ["test-support"] } openshell-driver-vm = { path = "../openshell-driver-vm", default-features = false, features = ["allocation-tracking"] } diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 61c342bc17..d7b4d2ed94 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -212,11 +212,18 @@ pub(crate) async fn prepare_inspected_request( L7Protocol::Rest | L7Protocol::Websocket | L7Protocol::Sql => {} } let owners = admitted_token_grant_owners(engine, &scoped, ¤t_info)?; + if engine.is_stale() { + return Err(miette!("policy changed before token grant resolution")); + } + if let Some(guard) = credential_generation_guard(&scoped) { + guard.ensure_current()?; + } crate::l7::token_grant_injection::inject_for_admitted_owners( req, &scoped, snapshot, &owners, + config, request_id, observation_context.filter(|context| { context.matches_config(&config.policy_hash, Some(snapshot.revision)) @@ -254,7 +261,7 @@ fn request_authority_matches_endpoint( request_host.eq_ignore_ascii_case(endpoint_host) && authority.effective_port == ctx.port } -fn normalized_endpoint_host(host: &str) -> &str { +pub(super) fn normalized_endpoint_host(host: &str) -> &str { host.trim() .trim_start_matches('[') .trim_end_matches(']') diff --git a/crates/openshell-supervisor-network/src/l7/relay/token_grant_ownership_tests.rs b/crates/openshell-supervisor-network/src/l7/relay/token_grant_ownership_tests.rs index eb34e325b9..13e6bc435c 100644 --- a/crates/openshell-supervisor-network/src/l7/relay/token_grant_ownership_tests.rs +++ b/crates/openshell-supervisor-network/src/l7/relay/token_grant_ownership_tests.rs @@ -8,6 +8,8 @@ //! or that a real OAuth server issues the requested token. The resolver returns //! inert values and records which provider and audience the relay requested. +mod provider_client_assertion_wire; + use super::*; use crate::l7::token_grant_injection::{TokenGrantRequest, TokenGrantResolver}; use crate::opa::{NetworkInput, OpaEngine}; @@ -278,12 +280,6 @@ impl Fixture { resolver: Arc, body_pending: Option>, ) -> Connection { - let configs = self.configs.clone(); - let middleware_engine = self.engine.clone(); - let engine = self - .engine - .clone_engine_for_tunnel(self.generation) - .expect("tunnel policy snapshot"); let ctx = L7EvalContext { host: HOST.into(), port: PORT, @@ -295,31 +291,13 @@ impl Fixture { endpoint_observation_tx: self.observation.clone(), ..Default::default() }; - let (app, client) = tokio::io::duplex(16 * 1024); - let mut client = BodyReadObservedStream { - stream: client, + Connection::new( + self.configs.clone(), + self.engine.clone(), + self.generation, + ctx, body_pending, - headers: Vec::new(), - headers_complete: false, - notified: false, - }; - let (mut relay_upstream, upstream) = tokio::io::duplex(16 * 1024); - let relay = tokio::spawn(async move { - crate::proxy::relay_inspected_http_stream_for_test( - &mut client, - &mut relay_upstream, - configs, - engine, - &middleware_engine, - &ctx, - ) - .await - }); - Connection { - app, - relay, - upstream: tokio::spawn(capture_upstream(upstream)), - } + ) } } @@ -447,6 +425,43 @@ async fn capture_upstream(mut stream: DuplexStream) -> Capture { } impl Connection { + fn new( + configs: Vec, + middleware_engine: Arc, + generation: u64, + ctx: L7EvalContext, + body_pending: Option>, + ) -> Self { + let engine = middleware_engine + .clone_engine_for_tunnel(generation) + .expect("tunnel policy snapshot"); + let (app, client) = tokio::io::duplex(16 * 1024); + let mut client = BodyReadObservedStream { + stream: client, + body_pending, + headers: Vec::new(), + headers_complete: false, + notified: false, + }; + let (mut relay_upstream, upstream) = tokio::io::duplex(16 * 1024); + let relay = tokio::spawn(async move { + crate::proxy::relay_inspected_http_stream_for_test( + &mut client, + &mut relay_upstream, + configs, + engine, + &middleware_engine, + &ctx, + ) + .await + }); + Self { + app, + relay, + upstream: tokio::spawn(capture_upstream(upstream)), + } + } + async fn exchange_body(&mut self, path: &str, body: &[u8]) -> String { let mut request = body_request(path, body); let version = b"MCP-Protocol-Version: 2025-11-25\r\n"; @@ -472,8 +487,12 @@ impl Connection { } else { "" }; + self.send_with_headers(method, path, authorization).await; + } + + async fn send_with_headers(&mut self, method: &str, path: &str, headers: &str) { let request = format!( - "{method} {path} HTTP/1.1\r\nHost: {HOST}:{PORT}\r\n{authorization}Content-Length: 0\r\n\r\n" + "{method} {path} HTTP/1.1\r\nHost: {HOST}:{PORT}\r\n{headers}Content-Length: 0\r\n\r\n" ); timeout(WAIT, self.app.write_all(request.as_bytes())) .await @@ -531,6 +550,14 @@ impl Connection { } fn assert_authorization(request: &str, audience: &str) { + assert_bearer_token(request, &format!("inert-{audience}")); + assert!( + !request.contains("stale-inert-value"), + "stale header removed" + ); +} + +fn assert_bearer_token(request: &str, token: &str) { let values = request .lines() .filter_map(|line| line.split_once(':')) @@ -539,13 +566,9 @@ fn assert_authorization(request: &str, audience: &str) { .collect::>(); assert_eq!(values.len(), 1, "exactly one Authorization replacement"); assert!( - values[0] == format!("Bearer inert-{audience}"), + values[0] == format!("Bearer {token}"), "expected owner token" ); - assert!( - !request.contains("stale-inert-value"), - "stale header removed" - ); } fn assert_no_authorization(capture: &Capture) { diff --git a/crates/openshell-supervisor-network/src/l7/relay/token_grant_ownership_tests/provider_client_assertion_wire.rs b/crates/openshell-supervisor-network/src/l7/relay/token_grant_ownership_tests/provider_client_assertion_wire.rs new file mode 100644 index 0000000000..02cf705bdc --- /dev/null +++ b/crates/openshell-supervisor-network/src/l7/relay/token_grant_ownership_tests/provider_client_assertion_wire.rs @@ -0,0 +1,510 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! HTTPS Gateway and OAuth broker with the real supervisor credential relay. +//! Exercises automatic Bearer and client-supplied header placement. + +use std::path::Path; +use std::process::Command; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use axum::{ + Json, Router, + extract::{Form, FromRequest, Request, State}, + http::{Method, StatusCode, header}, + response::{IntoResponse, Response}, +}; +use jsonwebtoken::{Algorithm, DecodingKey, Validation, jwk::JwkSet}; +use openshell_core::{ + grpc_client::{ + GatewayEndpoint, ProviderEnvironmentResult, fetch_provider_environment, + fetch_settings_snapshot, + }, + oauth::DEFAULT_CLIENT_ASSERTION_TYPE, + proto::{ + NetworkAccessPreset, NetworkBinary, NetworkEndpoint, ProviderCredentialTokenGrant, + ProviderCredentialTokenGrantType, ProviderProfile, ProviderProfileCategory, + ProviderProfileCredential, SandboxPolicy, + }, + provider_credentials::ProviderCredentialState, + token_grant_failure::REQUEST_ID_HEADER, +}; +use openshell_server::{ + TlsAcceptor, + test_support::provider_grant::{PROVIDER_ID, ProviderGrantGateway, SANDBOX, SIBLING}, +}; +use serde::Deserialize; +use tokio::{ + net::{TcpListener, TcpStream}, + task::JoinSet, +}; + +use super::{Connection, HOST, PORT, WAIT, assert_bearer_token}; +use crate::l7::relay::L7EvalContext; +use crate::l7::token_grant_injection::SpiffeTokenGrantResolver; +use crate::opa::{NetworkAction, NetworkInput, OpaEngine}; +use crate::token_grant::SandboxInstanceTokenGrants; + +#[cfg(target_os = "linux")] +mod placeholder_fetch; + +const CHILD_CERT_DIR: &str = "OPENSHELL_PROVIDER_ASSERTION_WIRE_CERT_DIR"; +const GATEWAY_ISSUER: &str = "openshell-gateway:wire-gateway"; +const CREDENTIAL: &str = "access_token"; +const ENV_KEY: &str = "WIRE_AUTHORIZATION"; +const PLACEHOLDER_VALUE: &str = "fixture-placeholder-source"; +const ACCESS_TOKEN: &str = "fixture-granted-access-token"; +const RESOURCE_AUDIENCE: &str = "api://wire-origin"; + +#[test] +fn provider_client_assertion_wire() { + let name = concat!(module_path!(), "::provider_client_assertion_wire"); + with_tls_fixture(name, |cert_dir| run_bounded(run(cert_dir), WAIT * 4)); +} + +fn run_bounded(exercise: impl Future, timeout: Duration) { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + tokio::time::timeout(timeout, exercise) + .await + .expect("bounded local wire test") + }); +} + +fn with_tls_fixture(full_name: &str, exercise: impl FnOnce(&Path)) { + if let Some(cert_dir) = std::env::var_os(CHILD_CERT_DIR) { + exercise(Path::new(&cert_dir)); + return; + } + + // TLS trust is process configuration for both real clients. Give this case + // an isolated process so other tests never observe its ephemeral CA. + let cert_dir = tempfile::tempdir().unwrap(); + let pki = openshell_otel_test_support::TestPki::generate(); + for (name, pem) in [ + ("ca.pem", pki.ca_pem), + ("server-cert.pem", pki.server.cert_pem), + ("server-key.pem", pki.server.key_pem), + ] { + std::fs::write(cert_dir.path().join(name), pem).unwrap(); + } + let ca = cert_dir.path().join("ca.pem"); + let (_, test_name) = full_name.split_once("::").expect("crate prefix"); + let output = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", test_name, "--nocapture"]) + .env(CHILD_CERT_DIR, cert_dir.path()) + .env("SSL_CERT_FILE", &ca) + .env_remove("SSL_CERT_DIR") + .env(openshell_core::sandbox_env::TLS_CA, &ca) + .env_remove(openshell_core::sandbox_env::GATEWAY_TLS_SERVER_NAME) + .output() + .expect("run wire test with isolated certificate trust"); + assert!( + output.status.success() + && String::from_utf8_lossy(&output.stdout).contains("1 passed; 0 failed"), + "wire test subprocess failed\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} + +async fn run(cert_dir: &Path) { + openshell_crypto::tls::ensure_default_provider(); + let target = "/wire"; + let environment = &[(ENV_KEY, PLACEHOLDER_VALUE)]; + let binary = crate::test_support::executable_path("/usr/bin/curl"); + let (gateway, broker) = start_gateway(cert_dir, environment, |url| { + profile(url, &binary, "bearer", target) + }) + .await; + + let policy = gateway_policy(&gateway.sandbox, SANDBOX).await; + let engine = Arc::new(OpaEngine::from_proto(&policy).expect("provider policy compiles")); + let delivery = fetch_provider_environment(&gateway.sandbox, SANDBOX) + .await + .expect("fetch real provider installation over TLS"); + assert!( + delivery + .dynamic_credentials + .values() + .any( + |credential| credential.token_grant_provider_id == PROVIDER_ID + && credential.auth_style == "bearer" + ), + "Gateway delivers the immutable provider identity and authored placement" + ); + let state = provider_state(delivery); + let child = state.snapshot(); + let workload_header = &child.child_env[ENV_KEY]; + assert!(workload_header.starts_with("openshell:resolve:env:")); + assert!( + child + .child_env + .values() + .all(|value| { !value.contains(PLACEHOLDER_VALUE) && !value.contains(ACCESS_TOKEN) }), + "workload environment excludes granted and stored credential values", + ); + + let authorization = engine + .authorize_egress(&NetworkInput { + host: HOST.into(), + port: PORT, + binary_path: binary.clone(), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }) + .expect("OPA resolves the provider route"); + let NetworkAction::Allow { + matched_policy: Some(policy_name), + } = authorization.action + else { + panic!("Gateway policy did not admit the fixture destination"); + }; + let [endpoint] = authorization.endpoint_configs.as_slice() else { + panic!("expected one provider endpoint"); + }; + let config = crate::l7::parse_l7_config(endpoint).unwrap(); + let headers = + format!("Authorization: Bearer {workload_header}\r\nauthorization: Bearer stale-value\r\n"); + let exchange = async |caller: &GatewayEndpoint, expected_status: &str| { + let ctx = L7EvalContext { + host: HOST.into(), + port: PORT, + workspace: "default".into(), + binary_path: binary.to_str().unwrap().into(), + policy_name: policy_name.clone(), + provider_credentials: Some(state.clone()), + request_default_port: Some(443), + // Each caller must reach acquisition instead of a cache hit. + token_grant_resolver: Some(Arc::new(SpiffeTokenGrantResolver::new( + SandboxInstanceTokenGrants::new(Some(caller.clone()), Some(SANDBOX.into()), None), + ))), + ..Default::default() + }; + let mut connection = Connection::new( + vec![config.clone()], + engine.clone(), + authorization.generation, + ctx, + None, + ); + connection.send_with_headers("GET", target, &headers).await; + let response = connection.response().await; + assert!(response.starts_with(expected_status)); + assert!(response.contains("Content-Length: 0\r\n")); + assert!(!response.contains(ACCESS_TOKEN)); + assert!(!response.contains(PLACEHOLDER_VALUE)); + connection.finish().await + }; + + // The sibling has a valid session, but cannot mint for this sandbox. + fetch_provider_environment(&gateway.sibling, SIBLING) + .await + .expect("sibling session authenticates for its own sandbox"); + assert_eq!( + exchange(&gateway.sibling, "HTTP/1.1 502 ").await.bytes_seen, + 0 + ); + broker.assert_outcomes(&[]); + + let capture = exchange(&gateway.sandbox, "HTTP/1.1 204 ").await; + let [origin] = capture.requests.as_slice() else { + panic!("expected one request at the origin"); + }; + assert!(origin.starts_with(&format!("GET {target} HTTP/1.1\r\n"))); + assert_bearer_token(origin, ACCESS_TOKEN); + assert!(!origin.contains(workload_header)); + assert!(!origin.contains(PLACEHOLDER_VALUE)); + broker.assert_outcomes(&[Outcome::Issued]); + + *broker.state.audience.lock().unwrap() = "https://other.example.test/token".into(); + assert_eq!( + exchange(&gateway.sandbox, "HTTP/1.1 502 ").await.bytes_seen, + 0 + ); + broker.assert_outcomes(&[Outcome::Issued, Outcome::WrongAudience]); +} + +fn gateway_tls(cert_dir: &Path) -> TlsAcceptor { + TlsAcceptor::from_files( + &cert_dir.join("server-cert.pem"), + &cert_dir.join("server-key.pem"), + None, + false, + None, + None, + Vec::new(), + ) + .expect("fixture Gateway TLS") +} + +async fn start_gateway( + cert_dir: &Path, + environment: &[(&str, &str)], + build_profile: impl FnOnce(&str) -> ProviderProfile, +) -> (ProviderGrantGateway, Broker) { + let tls = gateway_tls(cert_dir); + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let url = format!( + "https://localhost:{}/token", + listener.local_addr().unwrap().port() + ); + let gateway = ProviderGrantGateway::start( + tls.clone(), + build_profile(&url), + CREDENTIAL, + &url, + environment, + ) + .await; + let broker = Broker::start(listener, tls.acceptor(), &url, &gateway).await; + (gateway, broker) +} + +async fn gateway_policy(gateway: &GatewayEndpoint, sandbox: &str) -> SandboxPolicy { + let settings = fetch_settings_snapshot(gateway, sandbox) + .await + .expect("fetch gateway-composed policy over TLS"); + assert!( + settings.configuration_admitted, + "{}", + settings.configuration_error + ); + settings.policy.expect("gateway-composed provider policy") +} + +fn provider_state(delivery: ProviderEnvironmentResult) -> ProviderCredentialState { + ProviderCredentialState::from_bound_environment_with_stable_placeholders( + delivery.provider_env_revision, + delivery.environment, + delivery.credential_expires_at_ms, + delivery.dynamic_credentials, + delivery.static_credential_bindings, + delivery.non_secret_environment_keys, + delivery.stable_placeholder_environment_keys, + ) + .expect("provider installation accepted by supervisor") +} + +fn profile(broker_url: &str, binary: &Path, auth_style: &str, path: &str) -> ProviderProfile { + ProviderProfile { + id: "wire-grant".into(), + display_name: "Local wire grant".into(), + category: ProviderProfileCategory::Other as i32, + credentials: vec![ProviderProfileCredential { + name: CREDENTIAL.into(), + env_vars: vec![ENV_KEY.into()], + auth_style: auth_style.into(), + header_name: "Authorization".into(), + token_grant: Some(ProviderCredentialTokenGrant { + grant_type: ProviderCredentialTokenGrantType::OpenshellClientCredentials as i32, + token_endpoint: broker_url.into(), + audience: RESOURCE_AUDIENCE.into(), + scopes: vec!["wire.read".into(), "wire.inspect".into()], + ..Default::default() + }), + ..Default::default() + }], + endpoints: vec![NetworkEndpoint { + host: HOST.into(), + port: u32::from(PORT), + path: path.into(), + protocol: "rest".into(), + access: NetworkAccessPreset::Full as i32, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: binary.to_str().unwrap().into(), + ..Default::default() + }], + ..Default::default() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Outcome { + Issued, + WrongAudience, + InvalidRequest, + InvalidAssertion, +} + +struct Broker { + state: Arc, + _server: JoinSet<()>, +} + +struct BrokerState { + audience: Mutex, + key_id: String, + public_key: DecodingKey, + outcomes: Mutex>, +} + +impl Broker { + async fn start( + listener: TcpListener, + tls: tokio_rustls::TlsAcceptor, + audience: &str, + gateway: &ProviderGrantGateway, + ) -> Self { + let jwks = reqwest::Client::builder() + .no_proxy() + .build() + .expect("broker discovery client") + .get(format!("{}/.well-known/jwks.json", gateway.sandbox.url())) + .send() + .await + .expect("fetch Gateway JWKS over TLS") + .error_for_status() + .expect("Gateway publishes verification key") + .json::() + .await + .expect("read Gateway JWKS"); + let [jwk] = jwks.keys.as_slice() else { + panic!("expected one Gateway verification key"); + }; + let state = Arc::new(BrokerState { + audience: Mutex::new(audience.into()), + key_id: jwk.common.key_id.clone().expect("Gateway key ID"), + public_key: DecodingKey::from_jwk(jwk).expect("Gateway verification key"), + outcomes: Mutex::default(), + }); + let router = Router::new() + .fallback(token_request) + .with_state(state.clone()); + Self { + state, + _server: TlsListener(listener, tls).serve(router), + } + } + + fn assert_outcomes(&self, expected: &[Outcome]) { + assert_eq!(*self.state.outcomes.lock().unwrap(), expected); + } +} + +struct TlsListener(TcpListener, tokio_rustls::TlsAcceptor); + +impl axum::serve::Listener for TlsListener { + type Io = tokio_rustls::server::TlsStream; + type Addr = std::net::SocketAddr; + + async fn accept(&mut self) -> (Self::Io, Self::Addr) { + let (stream, addr) = self.0.accept().await.expect("fixture accept"); + openshell_core::net::set_tcp_nodelay_best_effort(&stream); + (self.1.accept(stream).await.expect("fixture TLS"), addr) + } + + fn local_addr(&self) -> std::io::Result { + self.0.local_addr() + } +} + +impl TlsListener { + fn serve(self, router: Router) -> JoinSet<()> { + let mut server = JoinSet::new(); + server.spawn(async move { axum::serve(self, router).await.unwrap() }); + server + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct OAuthForm { + grant_type: String, + client_assertion_type: String, + client_assertion: String, + audience: String, + scope: String, +} + +#[derive(Deserialize)] +struct AssertionClaims { + sub: String, + sandbox_id: String, + provider_id: String, + credential_key: String, + caller_kind: String, +} + +async fn token_request(State(state): State>, request: Request) -> Response { + let outcome = state.verify(request).await; + state.outcomes.lock().unwrap().push(outcome); + if outcome == Outcome::Issued { + Json(serde_json::json!({ + "access_token": ACCESS_TOKEN, + "expires_in": 300, + "token_type": "Bearer", + })) + .into_response() + } else { + ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"error": "invalid_client"})), + ) + .into_response() + } +} + +impl BrokerState { + async fn verify(&self, request: Request) -> Outcome { + if request.method() != Method::POST + || request.uri().path_and_query().map(|path| path.as_str()) != Some("/token") + || request.headers().contains_key(header::AUTHORIZATION) + || request + .headers() + .get(REQUEST_ID_HEADER) + .and_then(|value| value.to_str().ok()) + .and_then(|value| uuid::Uuid::parse_str(value).ok()) + .is_none() + { + return Outcome::InvalidRequest; + } + let Ok(Form(form)) = Form::::from_request(request, &()).await else { + return Outcome::InvalidRequest; + }; + if form.grant_type != "client_credentials" + || form.client_assertion_type != DEFAULT_CLIENT_ASSERTION_TYPE + || form.audience != RESOURCE_AUDIENCE + || form.scope != "wire.read wire.inspect" + { + return Outcome::InvalidRequest; + } + + let mut validation = Validation::new(Algorithm::EdDSA); + validation.leeway = 0; + validation.set_issuer(&[GATEWAY_ISSUER]); + validation.set_audience(&[self.audience.lock().unwrap().as_str()]); + validation.set_required_spec_claims(&["iss", "aud", "exp", "sub"]); + let token = match openshell_crypto::jwt::decode::( + &form.client_assertion, + &self.public_key, + &validation, + ) { + Ok(token) => token, + Err(error) if error.kind() == &jsonwebtoken::errors::ErrorKind::InvalidAudience => { + return Outcome::WrongAudience; + } + Err(_) => return Outcome::InvalidAssertion, + }; + let claims = token.claims; + if token.header.typ.as_deref() != Some("openshell-provider-assertion+jwt") + || token.header.kid.as_deref() != Some(self.key_id.as_str()) + || claims.sub != format!("openshell-sandbox:{SANDBOX}") + || claims.sandbox_id != SANDBOX + || claims.provider_id != PROVIDER_ID + || claims.credential_key != CREDENTIAL + || claims.caller_kind != "supervisor" + { + return Outcome::InvalidAssertion; + } + + Outcome::Issued + } +} diff --git a/crates/openshell-supervisor-network/src/l7/relay/token_grant_ownership_tests/provider_client_assertion_wire/placeholder_fetch.rs b/crates/openshell-supervisor-network/src/l7/relay/token_grant_ownership_tests/provider_client_assertion_wire/placeholder_fetch.rs new file mode 100644 index 0000000000..46ed7b576f --- /dev/null +++ b/crates/openshell-supervisor-network/src/l7/relay/token_grant_ownership_tests/provider_client_assertion_wire/placeholder_fetch.rs @@ -0,0 +1,479 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; + +use std::path::PathBuf; +use std::process::Stdio; +use std::sync::atomic::AtomicU32; + +use axum::body::{Body, to_bytes}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use openshell_core::policy::ProxyPolicy; +use tokio::{io::AsyncWriteExt, process::Command as AsyncCommand}; + +use crate::identity::BinaryIdentityCache; +use crate::l7::tls::{CertCache, ProxyTlsState, SandboxCa, build_upstream_client_config}; +use crate::policy_local::PolicyLocalContext; +use crate::proxy::ProxyHandle; + +const ORIGIN_HOST: &str = "host.openshell.internal"; +const REPOSITORY: &str = "/team/repo.git"; +const RAW_HEADER: &str = "x-wire-api-key"; +const CLIENT_USER: &str = "wire-repository-user"; + +#[test] +fn provider_client_assertion_git_placeholder_wire() { + run_placeholder_test( + concat!( + module_path!(), + "::provider_client_assertion_git_placeholder_wire" + ), + false, + ); +} + +#[test] +fn provider_client_assertion_raw_placeholder_wire() { + run_placeholder_test( + concat!( + module_path!(), + "::provider_client_assertion_raw_placeholder_wire" + ), + true, + ); +} + +fn run_placeholder_test(name: &str, raw: bool) { + with_tls_fixture(name, |cert_dir| { + run_bounded(exercise(cert_dir, raw), WAIT * 4); + }); +} + +async fn exercise(cert_dir: &Path, raw: bool) { + openshell_crypto::tls::ensure_default_provider(); + + let repo_root = tempfile::tempdir().unwrap(); + let server_repo = repo_root.path().join("team/repo.git"); + let (path, route, header_name, binary) = if raw { + ( + "/v1/fixture", + "/v1/fixture".to_string(), + RAW_HEADER, + std::env::current_exe().unwrap(), + ) + } else { + git_ok( + repo_root.path(), + &[ + "init", + "--initial-branch=main", + server_repo.to_str().unwrap(), + ], + ) + .await; + let exec_path = git_ok(repo_root.path(), &["--exec-path"]).await; + let helper = Path::new(&exec_path).join("git-remote-https"); + ( + REPOSITORY, + format!("{REPOSITORY}/**"), + "Authorization", + helper.canonicalize().unwrap(), + ) + }; + let origin = Origin::start(repo_root.path().to_path_buf(), raw).await; + let url = format!("https://{ORIGIN_HOST}:{}{path}", origin.port); + let (gateway, broker) = start_gateway(cert_dir, &[], |url| { + let mut profile = profile(url, &binary, "placeholder", &route); + profile.endpoints[0].host = ORIGIN_HOST.into(); + profile.endpoints[0].port = origin.port.into(); + profile.credentials[0].header_name = header_name.into(); + profile + }) + .await; + + let policy = gateway_policy(&gateway.sandbox, SANDBOX).await; + let delivery = fetch_provider_environment(&gateway.sandbox, SANDBOX) + .await + .unwrap(); + assert!( + delivery.environment.is_empty(), + "no stored or preinstalled credential" + ); + let state = provider_state(delivery); + let installed = state.snapshot(); + let handle = &installed.child_env[ENV_KEY]; + assert!(handle.starts_with("openshell:resolve:env:")); + assert!(!handle.contains(ACCESS_TOKEN)); + let proxy_ca = SandboxCa::generate().unwrap(); + let proxy_ca_path = cert_dir.join("proxy-ca.pem"); + std::fs::write(&proxy_ca_path, proxy_ca.cert_pem()).unwrap(); + let proxy = start_proxy( + gateway.sandbox.clone(), + state.clone(), + policy, + proxy_ca, + &origin.ca_pem, + ) + .await; + let proxy_url = format!("http://{}", proxy.http_addr().unwrap()); + + broker.assert_outcomes(&[]); + assert!( + origin.requests().is_empty(), + "startup must not contact the origin" + ); + if raw { + let client = reqwest::Client::builder() + .proxy(reqwest::Proxy::all(&proxy_url).unwrap()) + .add_root_certificate( + reqwest::Certificate::from_pem(&std::fs::read(proxy_ca_path).unwrap()).unwrap(), + ) + .build() + .unwrap(); + let response = client + .get(url) + .header(RAW_HEADER, handle) + .send() + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::NO_CONTENT, + "named header must reach the origin with one broker redemption", + ); + broker.assert_outcomes(&[Outcome::Issued]); + assert_eq!(origin.requests(), [(Method::GET, "/v1/fixture".into())]); + return; + } + + let first = publish_commit(&server_repo, "cold object").await; + fetch_fresh( + git_fetch(&url, &first, handle, &proxy_url, &proxy_ca_path), + &first, + ExpectedFetch::Present, + ) + .await; + let first_requests = origin.requests(); + assert_eq!( + first_requests.first(), + Some(&(Method::GET, format!("{REPOSITORY}/info/refs"))), + "Git starts with service discovery", + ); + let upload_pack = (Method::POST, format!("{REPOSITORY}/git-upload-pack")); + assert!(!first_requests[1..].is_empty()); + assert!( + first_requests[1..] + .iter() + .all(|request| *request == upload_pack) + ); + broker.assert_outcomes(&[Outcome::Issued]); + + gateway.detach().await; + // Install the live credential update while retaining the original route. + // Withdrawal must take effect even if the policy refresh has yet to arrive. + let withdrawn = fetch_provider_environment(&gateway.sandbox, SANDBOX) + .await + .unwrap(); + assert_ne!(withdrawn.provider_env_revision, installed.revision); + assert!(withdrawn.dynamic_credentials.is_empty()); + state + .install_bound_environment_with_stable_placeholders( + withdrawn.provider_env_revision, + withdrawn.environment, + withdrawn.credential_expires_at_ms, + withdrawn.dynamic_credentials, + withdrawn.static_credential_bindings, + withdrawn.non_secret_environment_keys, + withdrawn.stable_placeholder_environment_keys, + ) + .expect("install detached provider environment"); + assert!(!state.snapshot().child_env.contains_key(ENV_KEY)); + + let second = publish_commit(&server_repo, "object after withdrawal").await; + assert_ne!(first, second); + fetch_fresh( + git_fetch(&url, &second, handle, &proxy_url, &proxy_ca_path), + &second, + ExpectedFetch::Withdrawn, + ) + .await; + broker.assert_outcomes(&[Outcome::Issued]); + assert_eq!(origin.requests(), first_requests); +} + +async fn start_proxy( + gateway: GatewayEndpoint, + state: ProviderCredentialState, + policy: SandboxPolicy, + ca: SandboxCa, + origin_ca: &str, +) -> ProxyHandle { + let engine = Arc::new(OpaEngine::from_proto(&policy).expect("provider policy compiles")); + let (_workspace_tx, workspace) = tokio::sync::watch::channel("default".into()); + let grants = SandboxInstanceTokenGrants::new(Some(gateway.clone()), Some(SANDBOX.into()), None); + let context = PolicyLocalContext::new( + Some(policy), + Some(gateway), + Some(SANDBOX.into()), + Default::default(), + workspace, + ) + .with_token_grants(Arc::new(SpiffeTokenGrantResolver::new(grants))); + let tls = ProxyTlsState::new( + CertCache::new(ca), + build_upstream_client_config(origin_ca).unwrap(), + ); + let (_ready_tx, ready) = tokio::sync::watch::channel(true); + ProxyHandle::start_with_bind_addr( + &ProxyPolicy { http_addr: None }, + Some(([127, 0, 0, 1], 0).into()), + engine, + Arc::new(BinaryIdentityCache::new()), + Arc::new(AtomicU32::new(std::process::id())), + Some(Arc::new(tls)), + Some(state), + Some(Arc::new(context)), + None, + None, + None, + ready, + &Default::default(), + Some([127, 0, 0, 1].into()), + None, + None, + None, + None, + None, + ) + .await + .expect("start real supervisor CONNECT proxy") +} + +fn git() -> AsyncCommand { + let mut command = AsyncCommand::new("git"); + command + .env_clear() + .env("PATH", std::env::var_os("PATH").unwrap()) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_TERMINAL_PROMPT", "0") + .kill_on_drop(true); + command +} + +async fn git_ok(directory: &Path, args: &[&str]) -> String { + let output = git() + .current_dir(directory) + .args(args) + .output() + .await + .unwrap(); + assert!( + output.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().into() +} + +async fn has_commit(directory: &Path, oid: &str) -> bool { + git() + .current_dir(directory) + .args(["cat-file", "-e", &format!("{oid}^{{commit}}")]) + .stderr(Stdio::null()) + .status() + .await + .unwrap() + .success() +} + +async fn publish_commit(repository: &Path, message: &str) -> String { + std::fs::write(repository.join("fixture.txt"), message).unwrap(); + git_ok(repository, &["add", "fixture.txt"]).await; + git_ok( + repository, + &[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "-m", + message, + ], + ) + .await; + git_ok(repository, &["rev-parse", "HEAD"]).await +} + +fn git_fetch(url: &str, oid: &str, handle: &str, proxy: &str, ca: &Path) -> AsyncCommand { + // Git supplies the normal Basic header. Scope preauthentication to this + // repository so discovery and upload-pack both carry the opaque password. + let basic = STANDARD.encode(format!("{CLIENT_USER}:{handle}")); + let mut command = git(); + command + .env("HTTPS_PROXY", proxy) + .env("GIT_SSL_CAINFO", ca) + .args([ + "-c", + &format!("http.{url}.extraHeader=Authorization: Basic {basic}"), + "fetch", + "--no-tags", + url, + oid, + ]); + command +} + +enum ExpectedFetch { + Present, + Withdrawn, +} + +async fn fetch_fresh(mut command: AsyncCommand, oid: &str, expected: ExpectedFetch) { + assert!(oid.len() == 40 && oid.bytes().all(|byte| byte.is_ascii_hexdigit())); + let client = tempfile::tempdir().unwrap(); + git_ok(client.path(), &["init"]).await; + assert!(!has_commit(client.path(), oid).await); + let output = command.current_dir(client.path()).output().await.unwrap(); + let expected_present = matches!(expected, ExpectedFetch::Present); + assert_eq!( + output.status.code(), + Some(if expected_present { 0 } else { 128 }), + "Git fetch: {}", + String::from_utf8_lossy(&output.stderr), + ); + let present = has_commit(client.path(), oid).await; + assert_eq!(present, expected_present, "requested object presence"); + if present { + assert_eq!( + git_ok(client.path(), &["rev-parse", "FETCH_HEAD"]).await, + oid + ); + } +} + +struct Origin { + port: u16, + ca_pem: String, + state: Arc, + _server: JoinSet<()>, +} + +struct OriginState { + root: PathBuf, + raw: bool, + requests: Mutex>, +} + +impl Origin { + async fn start(root: PathBuf, raw: bool) -> Self { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let pki = openshell_otel_test_support::TestPki::for_server(ORIGIN_HOST); + let tls = openshell_otel_test_support::ServerTls::server_only(&pki).rustls_config(); + let state = Arc::new(OriginState { + root, + raw, + requests: Mutex::default(), + }); + let router = Router::new() + .fallback(origin_request) + .with_state(state.clone()); + Self { + port, + ca_pem: pki.ca_pem, + state, + _server: TlsListener(listener, tls.into()).serve(router), + } + } + + fn requests(&self) -> Vec<(Method, String)> { + self.state.requests.lock().unwrap().clone() + } +} + +async fn origin_request(State(state): State>, request: Request) -> Response { + let (parts, body) = request.into_parts(); + state + .requests + .lock() + .unwrap() + .push((parts.method.clone(), parts.uri.path().into())); + let (name, expected) = if state.raw { + (RAW_HEADER, ACCESS_TOKEN.into()) + } else { + ( + "authorization", + format!( + "Basic {}", + STANDARD.encode(format!("{CLIENT_USER}:{ACCESS_TOKEN}")) + ), + ) + }; + if parts + .headers + .get_all(name) + .iter() + .collect::>() + .as_slice() + != [expected.as_str()] + { + return StatusCode::UNAUTHORIZED.into_response(); + } + if state.raw { + return StatusCode::NO_CONTENT.into_response(); + } + let body = to_bytes(body, 1024 * 1024).await.unwrap(); + let mut backend = git(); + backend + .args(["http-backend"]) + .env("GIT_PROJECT_ROOT", &state.root) + .env("GIT_HTTP_EXPORT_ALL", "1") + .env("PATH_INFO", parts.uri.path()) + .env("REQUEST_METHOD", parts.method.as_str()) + .env("QUERY_STRING", parts.uri.query().unwrap_or_default()) + .env("CONTENT_LENGTH", body.len().to_string()) + .env( + "CONTENT_TYPE", + parts + .headers + .get(header::CONTENT_TYPE) + .map_or("", |h| h.to_str().unwrap()), + ) + .env( + "HTTP_GIT_PROTOCOL", + parts + .headers + .get("git-protocol") + .map_or("", |h| h.to_str().unwrap()), + ) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = backend.spawn().unwrap(); + child.stdin.take().unwrap().write_all(&body).await.unwrap(); + let output = child.wait_with_output().await.unwrap(); + assert!(output.status.success(), "stock git http-backend"); + let end = output + .stdout + .windows(4) + .position(|window| window == b"\r\n\r\n") + .unwrap(); + let mut response = Response::builder(); + for line in std::str::from_utf8(&output.stdout[..end]) + .unwrap() + .split("\r\n") + { + let (key, value) = line.split_once(": ").unwrap(); + if key.eq_ignore_ascii_case("status") { + response = response.status(value[..3].parse::().unwrap()); + } else { + response = response.header(key, value); + } + } + response + .body(Body::from(output.stdout[end + 4..].to_vec())) + .unwrap() +} diff --git a/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs b/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs index f868575097..5436423f43 100644 --- a/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs +++ b/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs @@ -17,6 +17,10 @@ use openshell_core::endpoint_failure::EndpointFailure; use openshell_core::endpoint_status::EndpointObservationContext; use openshell_core::proto::{ProviderCredentialTokenGrant, ProviderProfileCredential}; use openshell_core::provider_credentials::{ProviderCredentialSnapshot, ProviderCredentialState}; +use openshell_core::secrets::{ + SecretResolver, contains_grant_placeholder, header_contains_grant_placeholder, + valid_grant_placeholder_header, +}; use openshell_core::token_grant_failure::{ TokenGrantFailure, TokenGrantFailureKind, TokenGrantStage, }; @@ -27,12 +31,14 @@ use openshell_ocsf::{ use tracing::warn; use crate::l7::provider::L7Request; -use crate::l7::relay::L7EvalContext; +use crate::l7::relay::{L7EvalContext, normalized_endpoint_host}; +use crate::l7::{CredentialSigning, L7EndpointConfig, L7Protocol, TlsMode}; use crate::telemetry::egress_emit; /// Borrowed grant configuration for one endpoint-bound provider credential. pub struct TokenGrantRequest<'a> { pub provider_key: &'a str, + pub provider_id: &'a str, pub token_endpoint: &'a str, pub jwt_svid_audience: &'a str, pub client_assertion_type: &'a str, @@ -75,6 +81,7 @@ impl TokenGrantResolver for SpiffeTokenGrantResolver { self.grants .obtain_provider_token(crate::token_grant::ObtainProviderTokenRequest { provider_name: request.provider_key, + provider_id: request.provider_id, token_endpoint: request.token_endpoint, jwt_svid_audience: request.jwt_svid_audience, client_assertion_type: request.client_assertion_type, @@ -165,6 +172,17 @@ pub fn pin_live_credentials(ctx: &mut L7EvalContext) -> Option, +} + +struct PresentedPlaceholder { + handle: String, + value: String, +} + /// Inject the grants whose endpoint selectors match the request. /// /// L4-only forwarding uses this selector contract; it has no per-request L7 @@ -181,7 +199,7 @@ pub async fn inject_for_selectors( request_id: &str, observation_context: Option<&EndpointObservationContext>, ) -> Result { - let selected = select_scoped(snapshot, ctx, request_path(&req.target), None); + let selected = select_scoped(snapshot, ctx, &req, None, false); inject_or_report(req, ctx, selected, request_id, observation_context).await } @@ -197,6 +215,7 @@ pub(super) async fn inject_for_admitted_owners( ctx: &L7EvalContext, snapshot: &ProviderCredentialSnapshot, admitted_owners: &HashSet, + config: &L7EndpointConfig, request_id: &str, observation_context: Option<&EndpointObservationContext>, ) -> Result { @@ -218,7 +237,32 @@ pub(super) async fn inject_for_admitted_owners( key, )) } else { - select_scoped(snapshot, ctx, request_path, Some(admitted_owners)) + // A configured port alone cannot establish TLS. The HTTP transport + // sets its authority default after detecting TLS or selecting HTTPS. + // Require the gateway's credentialed-route marker so the downstream + // relay always inspects streamed body segments before releasing them. + let placeholder_allowed = config.protocol == L7Protocol::Rest + && config.tls == TlsMode::Auto + && config.provider_credentialed + && !config.allow_uninspected_credentials + && config.credential_signing == CredentialSigning::None + && ctx.request_default_port == Some(443) + && crate::l7::rest::request_authority(&req.raw_header, Some(443)).is_ok_and( + |authority| { + authority.is_some_and(|authority| { + authority.effective_port == ctx.port + && normalized_endpoint_host(authority.authority.host()) + .eq_ignore_ascii_case(normalized_endpoint_host(&ctx.host)) + }) + }, + ); + select_scoped( + snapshot, + ctx, + &req, + Some(admitted_owners), + placeholder_allowed, + ) }; inject_or_report(req, ctx, selected, request_id, observation_context).await } @@ -226,19 +270,105 @@ pub(super) async fn inject_for_admitted_owners( fn select_scoped( snapshot: &ProviderCredentialSnapshot, ctx: &L7EvalContext, - request_path: &str, + req: &L7Request, admitted_owners: Option<&HashSet>, -) -> Result, TokenGrantInjectionError> { + placeholder_allowed: bool, +) -> Result, TokenGrantInjectionError> { let candidates = token_grant_candidates( &snapshot.dynamic_credentials, ctx, - request_path, + request_path(&req.target), admitted_owners, )?; - Ok(select_token_grants(candidates)? - .into_iter() - .map(|(key, credential)| (snapshot.scoped_key(&key), credential)) - .collect()) + let selected = select_token_grants(candidates, placeholder_allowed)?; + select_present_grants(snapshot, req, selected) +} + +fn select_present_grants( + snapshot: &ProviderCredentialSnapshot, + req: &L7Request, + selected: Vec<(String, ProviderProfileCredential)>, +) -> Result, TokenGrantInjectionError> { + let refused = || TokenGrantInjectionError::new(injection_configuration_failure()); + let (header, end) = + parse_header_block(&req.raw_header).map_err(TokenGrantInjectionError::new)?; + // REST keeps the canonical path in req.target. The request line retains + // its query, and middleware may already have buffered a body. Only these + // available bytes can be checked before acquisition; the relay guards + // later body segments before forwarding them. + if contains_grant_placeholder(&req.target) + || header + .lines() + .next() + .is_some_and(contains_grant_placeholder) + || contains_grant_placeholder(&String::from_utf8_lossy(&req.raw_header[end + 4..])) + { + return Err(refused()); + } + let mut presented: Vec<_> = header + .split("\r\n") + .skip(1) + .filter_map(|line| line.split_once(':')) + .filter(|(_, value)| header_contains_grant_placeholder(value)) + .collect(); + let mut grants = Vec::with_capacity(selected.len()); + for (key, credential) in selected { + let placeholder = if is_placeholder_grant(&credential) { + let binding_refused = + || TokenGrantInjectionError::for_binding(injection_configuration_failure(), &key); + let name = token_grant_header_name(&credential).map_err(|_| binding_refused())?; + let Some(index) = presented + .iter() + .position(|(header, _)| header.eq_ignore_ascii_case(name)) + else { + // Let a client reach its normal authentication challenge. + continue; + }; + let (_, value) = presented.swap_remove(index); + let handle = snapshot + .grant_placeholder(&credential) + .ok_or_else(binding_refused)?; + if header + .split("\r\n") + .skip(1) + .filter_map(|line| line.split_once(':')) + .filter(|(header, _)| header.eq_ignore_ascii_case(name)) + .count() + != 1 + || crate::l7::rest::connection_nominated_header_names(&req.raw_header[..end + 4]) + .map_err(|_| binding_refused())? + .contains(&name.to_ascii_lowercase()) + || !SecretResolver::matches_grant_header_placeholder(value, handle) + { + return Err(binding_refused()); + } + Some(PresentedPlaceholder { + handle: handle.to_string(), + value: value.to_string(), + }) + } else { + None + }; + grants.push(SelectedGrant { + key: snapshot.scoped_key(&key), + credential, + placeholder, + }); + } + if !presented.is_empty() { + // A handle on a sibling route or in a different header cannot authorize + // acquisition of this grant or of another selected grant. + return Err(refused()); + } + Ok(grants) +} + +fn is_placeholder_grant(credential: &ProviderProfileCredential) -> bool { + credential.token_grant.is_some() + && credential + .auth_style + .trim() + .eq_ignore_ascii_case("placeholder") } /// Inject the selected grants, or record why the request was refused. @@ -248,7 +378,7 @@ fn select_scoped( async fn inject_or_report( req: L7Request, ctx: &L7EvalContext, - selected: Result, TokenGrantInjectionError>, + selected: Result, TokenGrantInjectionError>, request_id: &str, observation_context: Option<&EndpointObservationContext>, ) -> Result { @@ -266,8 +396,8 @@ async fn inject_or_report( .ok() .into_iter() .flatten() - .filter_map(|(key, _)| { - credential_identity(key)? + .filter_map(|grant| { + credential_identity(&grant.key)? .split_once(':') .map(|(provider, credential)| (provider.to_string(), credential.to_string())) }) @@ -365,7 +495,7 @@ fn report_refused_grant( async fn inject_selected( req: L7Request, ctx: &L7EvalContext, - credentials: Vec<(String, ProviderProfileCredential)>, + credentials: Vec, request_id: &str, ) -> Result { let resolver = ctx @@ -379,18 +509,42 @@ async fn inject_selected( // local until every selected grant and header has been validated, so a later // failure cannot expose a partially authenticated request to a relay caller. let mut headers = Vec::with_capacity(credentials.len()); - for (provider_key, cred) in &credentials { + for SelectedGrant { + key: provider_key, + credential: cred, + placeholder, + } in &credentials + { let refused = |failure| TokenGrantInjectionError::for_binding(failure, provider_key); let token_grant = cred .token_grant .as_ref() .ok_or_else(|| refused(injection_configuration_failure()))?; - let request = - token_grant_request(provider_key, token_grant, request_id).map_err(refused)?; + let request = token_grant_request( + provider_key, + &cred.token_grant_provider_id, + token_grant, + request_id, + ) + .map_err(refused)?; let access_token = resolver.obtain(request).await.map_err(refused)?; crate::token_grant::validate_access_token(&access_token) .map_err(|failure| refused(failure.at_stage(TokenGrantStage::Injection)))?; - headers.push(token_grant_header(cred, &access_token).map_err(refused)?); + let header = if let Some(placeholder) = placeholder { + let name = token_grant_header_name(cred).map_err(refused)?; + let value = SecretResolver::rewrite_grant_header_value( + &placeholder.value, + &placeholder.handle, + &access_token, + ) + .ok() + .flatten() + .ok_or_else(|| refused(injection_configuration_failure()))?; + (name.to_string(), value) + } else { + token_grant_header(cred, &access_token).map_err(refused)? + }; + headers.push(header); } let mut raw_header = req.raw_header; @@ -398,7 +552,7 @@ async fn inject_selected( raw_header = inject_header(&raw_header, &name, &value).map_err(TokenGrantInjectionError::new)?; } - for (provider_key, _) in credentials { + for grant in credentials { egress_emit!( HttpActivityBuilder::new(ocsf_ctx()) .activity(ActivityId::Other) @@ -413,7 +567,7 @@ async fn inject_selected( .message(format!( "Token grant successful for {} to {}:{}", ocsf_message_field( - credential_identity(&provider_key).unwrap_or("a dynamic credential") + credential_identity(&grant.key).unwrap_or("a dynamic credential") ), ctx.host, ctx.port @@ -437,6 +591,7 @@ fn injection_configuration_failure() -> TokenGrantFailure { fn select_token_grants( mut candidates: Vec<(u32, String, ProviderProfileCredential)>, + placeholder_allowed: bool, ) -> Result, TokenGrantInjectionError> { // Examine the strongest bindings first. The complete binding key stays with // its grant because the resolver uses it to separate cache entries and revisions. @@ -446,6 +601,9 @@ fn select_token_grants( let refused = || TokenGrantInjectionError::for_binding(injection_configuration_failure(), &key); let identity = credential_identity(&key).ok_or_else(refused)?; + if is_placeholder_grant(&credential) && !placeholder_allowed { + return Err(refused()); + } let header = token_grant_header_name(&credential) .map_err(|_| refused())? .to_ascii_lowercase(); @@ -511,6 +669,7 @@ fn ocsf_message_field(value: &str) -> String { fn token_grant_request<'a>( provider_key: &'a str, + provider_id: &'a str, token_grant: &'a ProviderCredentialTokenGrant, request_id: &'a str, ) -> Result, TokenGrantFailure> { @@ -527,6 +686,7 @@ fn token_grant_request<'a>( })?; Ok(TokenGrantRequest { provider_key, + provider_id, token_endpoint: &token_grant.token_endpoint, jwt_svid_audience: &token_grant.jwt_svid_audience, client_assertion_type: &token_grant.client_assertion_type, @@ -605,6 +765,9 @@ fn token_grant_header( credential: &ProviderProfileCredential, access_token: &str, ) -> Result<(String, String), TokenGrantFailure> { + if is_placeholder_grant(credential) { + return Err(injection_configuration_failure()); + } let header_name = token_grant_header_name(credential)?; let header_value = if credential.auth_style.trim().eq_ignore_ascii_case("header") { access_token.to_string() @@ -625,6 +788,22 @@ fn token_grant_header_name( credential.header_name.trim() } } + "placeholder" + if credential.query_param.trim().is_empty() + && credential.path_template.trim().is_empty() + && credential.refresh.is_none() => + { + let name = credential.header_name.trim(); + let name = if name.is_empty() { + "Authorization" + } else { + name + }; + if !valid_grant_placeholder_header(name) { + return Err(injection_configuration_failure()); + } + name + } // Header auth names its own header; there is no default to fall back to. "header" if !credential.header_name.trim().is_empty() => credential.header_name.trim(), _ => return Err(injection_configuration_failure()), @@ -634,34 +813,13 @@ fn token_grant_header_name( } fn validate_header_name(header_name: &str) -> Result<(), TokenGrantFailure> { - let valid = !header_name.is_empty() - && header_name.bytes().all(|byte| { - byte.is_ascii_alphanumeric() - || matches!( - byte, - b'!' | b'#' - | b'$' - | b'%' - | b'&' - | b'\'' - | b'*' - | b'+' - | b'-' - | b'.' - | b'^' - | b'_' - | b'`' - | b'|' - | b'~' - ) - }); + let name = http::HeaderName::from_bytes(header_name.as_bytes()) + .map_err(|_| injection_configuration_failure())?; // A grant may not override HTTP framing or connection headers. - if !valid - || matches!( - header_name.to_ascii_lowercase().as_str(), - "host" | "content-length" | "transfer-encoding" | "connection" - ) - { + if matches!( + name.as_str(), + "host" | "content-length" | "transfer-encoding" | "connection" + ) { return Err(injection_configuration_failure()); } Ok(()) @@ -1004,6 +1162,7 @@ pub mod test_support { grant_type: ProviderCredentialTokenGrantType::ClientCredentials as i32, subject_token: None, requested_token_type: String::new(), + workload_credential_handle: String::new(), } } @@ -1018,6 +1177,7 @@ pub mod test_support { subject_token_type: "urn:ietf:params:oauth:token-type:id_token".to_string(), }), requested_token_type: "urn:ietf:params:oauth:token-type:access_token".to_string(), + workload_credential_handle: String::new(), ..token_grant() } } @@ -1268,10 +1428,18 @@ mod tests { } #[test] - fn token_grant_header_rejects_framing_and_connection_headers() { - for header_name in ["Host", "Content-Length", "Transfer-Encoding", "Connection"] { + fn token_grant_header_rejects_invalid_framing_and_connection_headers() { + for header_name in [ + "Host", + "Content-Length", + "Transfer-Encoding", + "Connection", + "Bad Name", + "Bad:Name", + &"x".repeat(65536), + ] { let err = token_grant_header(&credential("header", header_name), "grant-token") - .expect_err("framing header override should be rejected"); + .expect_err("invalid or reserved header name"); assert_eq!(err, injection_configuration_failure()); } } diff --git a/crates/openshell-supervisor-network/src/l7/token_grant_injection/multiple_grants.rs b/crates/openshell-supervisor-network/src/l7/token_grant_injection/multiple_grants.rs index b4c3253672..e28dc0f86d 100644 --- a/crates/openshell-supervisor-network/src/l7/token_grant_injection/multiple_grants.rs +++ b/crates/openshell-supervisor-network/src/l7/token_grant_injection/multiple_grants.rs @@ -68,6 +68,113 @@ async fn malformed_endpoint_key_fails_before_acquisition() { fixture.assert_no_requests(); } +#[tokio::test] +async fn placeholder_grant_requires_the_current_handle_and_an_admitted_https_route() { + let fixture = TokenGrantTestFixture::success(SERVICE, "service-token"); + { + let credentials = fixture.dynamic_credentials(); + let mut credentials = credentials.write().unwrap(); + let credential = credentials.get_mut(SERVICE).unwrap(); + credential.auth_style = "placeholder".into(); + credential.header_name.clear(); + credential.env_vars = vec!["API_TOKEN".into()]; + credential.token_grant_provider_id = "provider-id".into(); + credential + .token_grant + .as_mut() + .unwrap() + .workload_credential_handle = "a".repeat(64); + } + let state = fixture.provider_credentials(); + let ctx = L7EvalContext { + host: "api.example.com".into(), + port: 443, + request_default_port: Some(443), + provider_credentials: Some(state.clone()), + token_grant_resolver: Some(fixture.resolver()), + ..Default::default() + }; + let snapshot = state.snapshot(); + let handle = &snapshot.child_env["API_TOKEN"]; + let request = |headers: &str| L7Request { + action: "GET".into(), + target: "/v1/projects".into(), + query_params: HashMap::new(), + raw_header: format!("GET /v1/projects HTTP/1.1\r\nHost: api.example.com\r\n{headers}\r\n") + .into_bytes(), + body_length: BodyLength::None, + }; + let owners = HashSet::from(["test-owner".to_owned()]); + let endpoint = + regorus::Value::from_json_str(r#"{"protocol":"rest","provider_credentialed":true}"#) + .unwrap(); + let config = crate::l7::parse_l7_config(&endpoint).unwrap(); + let eligible = format!("Authorization: Bearer {handle}\r\n"); + let inject = async |req, + context: &L7EvalContext, + admitted: &HashSet, + route: &L7EndpointConfig| { + inject_for_admitted_owners(req, context, &snapshot, admitted, route, REQUEST_ID, None).await + }; + let mut unguarded = config.clone(); + unguarded.provider_credentialed = false; + let mut plaintext = ctx.clone(); + plaintext.request_default_port = Some(80); + for (context, admitted, route) in [ + (&ctx, &owners, &unguarded), + (&ctx, &HashSet::new(), &config), + (&plaintext, &owners, &config), + ] { + assert!( + inject(request(&eligible), context, admitted, route) + .await + .is_err() + ); + } + assert!( + inject_if_needed(request(&eligible), &ctx, REQUEST_ID) + .await + .is_err() + ); + for headers in [ + format!("X-Unrelated: {handle}\r\n"), + format!("{eligible}authorization: another-value\r\n"), + format!("{eligible}Connection: Authorization\r\n"), + format!( + "Authorization: {}\r\n", + handle.replace(&"a".repeat(64), &"b".repeat(64)) + ), + ] { + assert!( + inject(request(&headers), &ctx, &owners, &config) + .await + .is_err() + ); + } + let mut misplaced_query = request(&eligible); + misplaced_query.raw_header = format!( + "GET /v1/projects?probe={handle} HTTP/1.1\r\nHost: api.example.com\r\n{eligible}\r\n" + ) + .into_bytes(); + assert!( + inject(misplaced_query, &ctx, &owners, &config) + .await + .is_err() + ); + let challenge = request("").raw_header; + let forwarded = inject(request(""), &ctx, &owners, &config).await.unwrap(); + assert_eq!(forwarded.raw_header, challenge); + fixture.assert_no_requests(); + + let admitted = inject(request(&eligible), &ctx, &owners, &config) + .await + .unwrap(); + let headers = String::from_utf8(admitted.raw_header).unwrap(); + assert!(headers.contains("Authorization: Bearer service-token\r\n")); + assert!(!headers.contains(handle)); + fixture.assert_one_request(SERVICE); +} + #[tokio::test] async fn injects_independent_grants_and_replaces_all_protected_headers() { let (fixture, ctx) = fixture(Ok("identity-token")); @@ -310,6 +417,8 @@ async fn admitted_owners_select_grants_independently_per_header() { (&["identity-owner"], false, true), (&["test-owner", "identity-owner"], true, true), ]; + let endpoint = regorus::Value::from_json_str(r#"{"protocol":"rest"}"#).unwrap(); + let config = crate::l7::parse_l7_config(&endpoint).unwrap(); for (admitted, service, identity) in cases { let (fixture, mut ctx) = fixture(Ok("identity-token")); fixture @@ -331,6 +440,7 @@ async fn admitted_owners_select_grants_independently_per_header() { &ctx, &state.snapshot(), &owners, + &config, REQUEST_ID, None, ) diff --git a/crates/openshell-supervisor-network/src/token_grant.rs b/crates/openshell-supervisor-network/src/token_grant.rs index 83d36d36b3..5776d9ec39 100644 --- a/crates/openshell-supervisor-network/src/token_grant.rs +++ b/crates/openshell-supervisor-network/src/token_grant.rs @@ -1,21 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! `OAuth2` JWT client assertion token grant using SPIFFE JWT-SVID. +//! `OAuth2` token acquisition for endpoint-bound provider credentials. //! -//! When a provider profile includes a `token_grant` configuration, the -//! supervisor obtains `OAuth2` access tokens on-demand by authenticating to the -//! token service using the sandbox's SPIFFE JWT-SVID as the client assertion. -//! -//! ## Flow -//! -//! 1. HTTP proxy intercepts outbound request to provider endpoint -//! 2. Check token cache for unexpired access token -//! 3. On cache miss or expiry: -//! a. Fetch JWT-SVID from SPIRE agent (via Workload API) -//! b. POST to token service with JWT client assertion grant -//! c. Cache the returned access token with TTL -//! 4. Inject `Authorization: Bearer ` header +//! The supervisor normally authenticates using the sandbox's SPIFFE JWT-SVID. +//! A `client_credentials` profile with `assertion_source: openshell_gateway` uses a dedicated, gateway-signed +//! assertion for its registered token endpoint. Each grant returns an access +//! token for injection at the matching provider endpoint. //! //! ## Configuration //! @@ -32,7 +23,7 @@ //! Each sandbox instance owns a [`SandboxInstanceTokenGrants`]: its token cache, //! HTTP client, gateway credentials, sandbox ID, and SPIFFE Workload API //! socket. A token is never served to another sandbox. Without a configured -//! Workload API socket, grants fail closed with a configuration failure. +//! Workload API socket, SPIFFE-based grants fail closed. use std::collections::HashMap; use std::future::Future; @@ -116,7 +107,7 @@ pub struct SandboxInstanceTokenGrants { impl SandboxInstanceTokenGrants { /// Grants authenticated as `sandbox_id` through `gateway`. A missing - /// Workload API socket makes every grant fail closed. + /// Workload API socket disables SPIFFE-based grants. #[must_use] pub fn new( gateway: Option, @@ -134,10 +125,8 @@ impl SandboxInstanceTokenGrants { /// Obtain an `OAuth2` access token for a provider using JWT client assertion grant. /// - /// This fetches the sandbox's SPIFFE JWT-SVID from the instance's Workload - /// API socket, then exchanges it for an access token with a POST request to - /// the provider's token endpoint with the JWT client assertion grant flow - /// (RFC 7523). + /// The selected grant determines whether the assertion comes from the + /// sandbox's Workload API socket or the gateway signing authority. /// /// Tokens are cached per provider name with TTL in this instance's cache. /// Subsequent calls return the cached token if it has not expired. @@ -145,7 +134,7 @@ impl SandboxInstanceTokenGrants { /// # Errors /// /// Returns an error if: - /// - SPIFFE Workload API socket is not configured + /// - The selected identity source is unavailable or refuses the request /// - SPIRE agent is unreachable /// - JWT-SVID fetch fails /// - Token service request fails @@ -156,6 +145,17 @@ impl SandboxInstanceTokenGrants { ) -> Result { let grant_type = ProviderCredentialTokenGrantType::try_from(request.grant_type) .map_err(|_| configuration_failure())?; + if grant_type == ProviderCredentialTokenGrantType::OpenshellClientCredentials + && (request.provider_id.is_empty() + || !request.jwt_svid_audience.is_empty() + || !request.client_assertion_type.is_empty() + || !request.requested_token_type.is_empty() + || !openshell_core::provider_credentials::provider_assertion_endpoint( + request.token_endpoint, + )) + { + return Err(configuration_failure()); + } obtain_provider_token_with_grant( ObtainProviderTokenInput { cache: &self.cache, @@ -170,10 +170,41 @@ impl SandboxInstanceTokenGrants { requested_token_type: request.requested_token_type, }, |jwt_audience| async move { + let http_client = self.http_client.as_ref().map_err(|failure| *failure)?; // Resolve local exchange prerequisites before fetching any workload credential. let exchange_context = match grant_type { ProviderCredentialTokenGrantType::ClientCredentials | ProviderCredentialTokenGrantType::Unspecified => None, + ProviderCredentialTokenGrantType::OpenshellClientCredentials => { + let (provider, credential_key) = + parse_provider_credential_key(request.provider_name)?; + let gateway = self.gateway.as_ref().ok_or_else(configuration_failure)?; + let sandbox_id = + self.sandbox_id.as_ref().ok_or_else(configuration_failure)?; + let assertion = + openshell_core::grpc_client::mint_provider_client_assertion( + gateway, + openshell_core::proto::MintProviderClientAssertionRequest { + sandbox_id: sandbox_id.clone(), + provider: provider.into(), + credential_key: credential_key.into(), + token_endpoint: request.token_endpoint.into(), + expected_provider_id: request.provider_id.into(), + }, + request.request_id, + ) + .await?; + return perform_token_grant( + http_client, + request.token_endpoint, + &assertion, + "", + request.audience, + request.scopes, + request.request_id, + ) + .await; + } ProviderCredentialTokenGrantType::TokenExchange => { let (provider, credential_key) = parse_provider_credential_key(request.provider_name)?; @@ -186,7 +217,6 @@ impl SandboxInstanceTokenGrants { Some((provider, credential_key, gateway, sandbox_id)) } }; - let http_client = self.http_client.as_ref().map_err(|failure| *failure)?; let socket_path = self .workload_api_socket .as_deref() @@ -250,6 +280,8 @@ impl SandboxInstanceTokenGrants { /// One provider token request; see [`SandboxInstanceTokenGrants::obtain_provider_token`]. pub struct ObtainProviderTokenRequest<'a> { pub provider_name: &'a str, + /// Immutable identity from gateway-delivered installed credential metadata. + pub provider_id: &'a str, pub token_endpoint: &'a str, pub jwt_svid_audience: &'a str, pub client_assertion_type: &'a str, @@ -382,7 +414,7 @@ fn configuration_failure() -> TokenGrantFailure { async fn perform_token_grant( http_client: &TokenGrantClient, token_endpoint: &str, - jwt_svid: &str, + assertion: &str, client_assertion_type: &str, audience: &str, scopes: &[String], @@ -392,7 +424,7 @@ async fn perform_token_grant( http_client, token_endpoint, &TokenGrantParams { - client_assertion: jwt_svid, + client_assertion: assertion, client_assertion_type, audience, scopes, @@ -663,6 +695,50 @@ mod tests { const TEST_REQUEST_ID: &str = "4f1c2b3a-9d8e-4c7b-a6f5-e4d3c2b1a098"; + #[tokio::test] + async fn token_grant_identity_mode_selects_only_its_own_source() { + // An insecure gateway is rejected before connection. A SPIFFE grant + // instead reaches the missing socket check on the same instance. + let gateway = GatewayEndpoint::new( + "http://localhost:1", + openshell_core::grpc_client::GatewayCredentials::from_gateway_token("synthetic") + .unwrap(), + ); + let grants = + SandboxInstanceTokenGrants::new(Some(gateway), Some("sandbox-id".into()), None); + let request = |grant_type: ProviderCredentialTokenGrantType| ObtainProviderTokenRequest { + provider_name: "attached:access", + provider_id: "provider-id", + token_endpoint: "https://issuer.example/token", + jwt_svid_audience: "", + client_assertion_type: "", + audience: "", + scopes: &[], + cache_ttl_override: None, + grant_type: grant_type as i32, + requested_token_type: "", + request_id: TEST_REQUEST_ID, + }; + let assertion = grants + .obtain_provider_token(request( + ProviderCredentialTokenGrantType::OpenshellClientCredentials, + )) + .await + .unwrap_err(); + assert_eq!(assertion.stage(), TokenGrantStage::GatewayRpc); + for mode in [ + ProviderCredentialTokenGrantType::Unspecified, + ProviderCredentialTokenGrantType::ClientCredentials, + ProviderCredentialTokenGrantType::TokenExchange, + ] { + let err = grants + .obtain_provider_token(request(mode)) + .await + .unwrap_err(); + assert_eq!(err.stage(), TokenGrantStage::Configuration); + } + } + fn test_http_client() -> TokenGrantClient { TokenGrantClient::new().expect("token grant HTTP client") } diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 0e807cd452..90f736f43e 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -2327,6 +2327,11 @@ async fn fetch_providers( page_size: PROVIDER_PROFILE_PAGE_SIZE, page_token, }; + let mut req = tonic::Request::new(req); + req.metadata_mut().insert( + openshell_providers::GATEWAY_ASSERTION_PROFILE_CAPABILITY, + tonic::metadata::MetadataValue::from_static("1"), + ); match tokio::time::timeout( Duration::from_secs(5), client.list_provider_profiles(req), @@ -2335,6 +2340,14 @@ async fn fetch_providers( { Ok(Ok(response)) => { let response = response.into_inner(); + if response.profiles.iter().any(|profile| { + openshell_providers::unknown_token_grant_type_diagnostic( + "gateway", profile, + ) + .is_some() + }) { + return None; + } Some((response.profiles, response.next_page_token)) } _ => None, diff --git a/docs/how-it-works/gateways/configuration.mdx b/docs/how-it-works/gateways/configuration.mdx index 408ac0977a..bff62227ab 100644 --- a/docs/how-it-works/gateways/configuration.mdx +++ b/docs/how-it-works/gateways/configuration.mdx @@ -553,6 +553,27 @@ allow_unauthenticated_users = true `[openshell.gateway.auth] allow_unauthenticated_users = true` is an unsafe local-development and trusted-proxy escape hatch. It accepts user-facing CLI/API calls without OIDC or mTLS credentials while sandbox supervisors still authenticate with gateway-minted sandbox JWTs. Leave it false for shared and production gateways. +## Provider client assertions + +To use a provider profile with `grant_type: client_credentials` and `assertion_source: openshell_gateway`, configure gateway JWT signing, enable TLS on the supervisor-to-gateway connection, and register the provider instance and credential in the gateway configuration. Set the same canonical HTTPS URL, including the path, in the profile and registration. Omit user information, query parameters and fragments. Leave `jwt_svid_audience`, `client_assertion_type`, `requested_token_type`, and `subject_token` unset. + +```toml +[[openshell.gateway.provider_assertions]] +provider_id = "" +credential_key = "access_token" +token_endpoint = "https://issuer.example/oauth/token" +``` + +The gateway checks the authenticated sandbox's current provider attachment, profile and immutable provider ID before signing. A same-name replacement needs its own registration. Each replica allows 60 assertion requests per sandbox per minute, separate from extension credential minting. + +The supervisor sends the 60-second JWT to the registered endpoint in the OAuth2 `client_assertion` form field. Before issuing an access token, the token service must: + +- Verify the signature against the configured gateway's JWKS. Require EdDSA, type `openshell-provider-assertion+jwt`, the expected issuer, the exact token endpoint as audience, and a valid expiration. +- Require `caller_kind=supervisor` and `sub=openshell-sandbox:` matching the signed `sandbox_id`. Reject reused assertion IDs (`jti`) through expiry. +- Authorize the issuer, sandbox, immutable `provider_id`, and `credential_key` for the requested resource audience and every scope, including defaults when the request omits them. + +Upgrade all gateway replicas, supervisors and profile tools before opting in. Older tools can drop the source selection when processing a profile offline. The CLI checks gateway support before writes. A supporting gateway requires capable clients to read or write opted-in profiles and refuses a silent downgrade to SPIFFE. To change the source deliberately or roll back, remove opted-in credentials and registrations with the compatible tools first. Removing a registration stops new assertions; previously issued access tokens can remain usable until expiry. + ## Service Routing Sandbox service URLs default to a readable composite hostname: diff --git a/docs/how-it-works/providers/profiles.mdx b/docs/how-it-works/providers/profiles.mdx index 94d668751d..ada3b9907b 100644 --- a/docs/how-it-works/providers/profiles.mdx +++ b/docs/how-it-works/providers/profiles.mdx @@ -528,7 +528,7 @@ import for compatibility. `category` supplies the `CATEGORY` column in `openshell profile list`. Use one of the values in the category enum. -`credentials` declares the credential names, environment variables, auth metadata, optional refresh metadata, and optional dynamic token grant metadata for the provider type. The `auth_style` field accepts `basic`, `bearer`, `header`, `query`, or `path`. When `auth_style` is `path`, set `path_template` to a URL path containing the `{credential}` placeholder exactly once (for example, `/v1/{credential}/resources`). Static credentials are exposed as placeholder environment variables and resolved in outbound HTTP requests only at their binding endpoints. Every static credential environment key receives the full profile endpoint set when the profile defines endpoints. An endpointless profile requires explicit sandbox policy bindings for each attached provider instance. Dynamic token grants are resolved by the sandbox proxy on demand for matching profile endpoints and support `bearer` or `header` placement. Credential environment variable names must not use the reserved `v_` prefix, such as `v10_GITHUB_TOKEN`, because OpenShell uses that namespace for revision-scoped placeholders. +`credentials` declares the credential names, environment variables, auth metadata, optional refresh metadata, and optional dynamic token grant metadata for the provider type. For static credentials, `auth_style` accepts `basic`, `bearer`, `header`, `query`, or `path`. When `auth_style` is `path`, set `path_template` to a URL path containing the `{credential}` placeholder exactly once (for example, `/v1/{credential}/resources`). Static credentials are exposed as placeholder environment variables and resolved in outbound HTTP requests only at their binding endpoints. Every static credential environment key receives the full profile endpoint set when the profile defines endpoints. An endpointless profile requires explicit sandbox policy bindings for each attached provider instance. The sandbox proxy resolves dynamic token grants on demand for matching profile endpoints. See [Dynamic Token Grants](#dynamic-token-grants) for the supported placements. Credential environment variable names must not use the reserved `v_` prefix, such as `v10_GITHUB_TOKEN`, because OpenShell uses that namespace for revision-scoped placeholders. Credential names must not contain control characters such as tabs or newlines. Dynamic token-grant endpoint and audience-override selectors have the same restriction. @@ -607,6 +607,12 @@ The refresh attaches to the primary credential (`access_key_id`). Each reference `token_grant` belongs to one credential declaration. When a sandbox with the provider attached sends HTTP traffic to a matching profile endpoint, the supervisor resolves the dynamic credentials, caches each returned access token separately, and injects them before forwarding the request upstream. Use `auth_style: bearer` to inject `Authorization: Bearer `, or `auth_style: header` with `header_name` to inject the raw access token into a custom header. Token grants do not support `query` or `path` placement. +Use `auth_style: placeholder` to let the client choose the header value's authentication format. Declare exactly one environment key with `env_vars` and set `header_name` to the allowed request header. The default is `Authorization`. OpenShell sets the environment key to an opaque handle when it launches a process. A client can send the handle as a raw header value, after an authentication scheme such as `Bearer`, or as the password in a Basic authentication value. The client supplies the Basic username. + +Configure a `protocol: rest` endpoint with TLS inspection and an endpoint path scoped to the protected service. The supervisor replaces the exact current handle only in the configured header and only after the endpoint's policy admits the request over inspected HTTPS. A request without the handle can receive the upstream service's normal authentication challenge. Duplicate, stale, misplaced, and ambiguous handles cannot select a grant. A grant token stays in the supervisor until it is used on an authorized upstream request. + +Placeholder grants require a gateway and supervisor that support this placement. A profile cannot set `tls: skip`, `allow_uninspected_credentials`, endpoint credential signing, or credential query, path, or refresh settings for this placement. Store the provider instance with runtime credentials only when all its credentials are grants. + Declare multiple credentials with different destination headers when one request needs independently issued tokens. Each credential keeps its own token endpoint, audience, scopes, and cache lifetime. For example, this profile sends a service bearer token and a separate workload token on the same inspected request: ```yaml @@ -643,7 +649,7 @@ The custom header carries the identity issuer's returned access token. To carry The supervisor chooses the most-specific matching binding independently for each destination header. Bindings that target the same header are alternatives; equal-specificity matches from different credentials are rejected. Header names are case-insensitive. Omitting both `auth_style` and `header_name` selects `Authorization`; an explicit `bearer` or `header` style requires `header_name`. A more-specific audience override for one credential does not suppress grants for other headers. -All selected grants must succeed and return valid header values before the request is rewritten. A failed grant returns HTTP 502 without forwarding the request. An earlier successful grant may remain in its own cache for a later attempt. Injection replaces every agent-supplied instance of each selected header, including any static credential placeholder in that header, and preserves unrelated headers. Issuer error descriptions are excluded from grant-failure logs and client errors because they may contain credentials. +All selected grants must succeed and return valid header values before the request is rewritten. A failed grant returns HTTP 502 without forwarding the request. An earlier successful grant may remain in its own cache for a later attempt. Automatic `bearer` and `header` injection replace all instances of the selected header. Placeholder injection rewrites the single eligible header value. Both preserve unrelated headers. Issuer error descriptions are excluded from grant-failure logs and client errors because they may contain credentials. REST, GraphQL, MCP and JSON-RPC inspection use the same credential preparation step before forwarding each request, including plaintext HTTP forward-proxy requests and CONNECT tunnels. This also applies when endpoints share a host and port or the client reuses a connection. The supervisor selects grants only from endpoints whose policy admits the current request and calling binary. It checks the body again after middleware transformations before selecting credentials. An overlapping endpoint's allow rule cannot authorize another endpoint's grant. Requests forwarded solely because enforcement is `audit` receive no grant from an endpoint that denies them. A gateway-global policy supplies the effective authorization rules when it replaces provider policies; profile host, port and path selectors still limit each credential's destination. L4-only HTTP forwarding retains host, port and path credential selection without L7 owner admission. @@ -653,12 +659,13 @@ Upgrade the gateway before the supervisor: inspected requests with a matching to Later requests use the latest installed provider credentials. If provider state or policy changes while a grant is being obtained, the supervisor rejects the request before sending it upstream. Reconnect and retry after the new configuration is ready. A token-service failure also closes the request without forwarding it; a later connection can retry the grant. -OpenShell supports two dynamic grant types: +The profile selects how the supervisor authenticates to the token endpoint. -| Grant type | Behavior | -|---|---| -| `client_credentials` | The supervisor requests a SPIFFE JWT-SVID from the local Workload API and sends it directly to `token_endpoint` as the OAuth2 client assertion. This is the default when `grant_type` is omitted. | -| `token_exchange` | The supervisor first asks the gateway for an intermediate token. The request includes the supervisor JWT-SVID; the gateway verifies it, uses the SVID subject as the intermediate token audience, and exchanges the stored subject credential at the same `token_endpoint` using the gateway's own JWT-SVID as the client assertion. The supervisor then exchanges that intermediate token for the final upstream token using its own JWT-SVID as the client assertion. | +| Grant type | Assertion source | Behavior | +|---|---|---| +| `client_credentials` | `spiffe` (default) | The supervisor requests a SPIFFE JWT-SVID from the local Workload API and sends it to `token_endpoint` as the OAuth2 client assertion. | +| `client_credentials` | `openshell_gateway` | The supervisor requests a dedicated gateway-signed JWT for the exact HTTPS token endpoint and submits it as the OAuth2 client assertion. Configure a [gateway registration](/how-it-works/gateways/configuration#provider-client-assertions) for this provider instance and credential. | +| `token_exchange` | `spiffe` | The supervisor first asks the gateway for an intermediate token. The request includes the supervisor JWT-SVID; the gateway verifies it, uses the SVID subject as the intermediate token audience, and exchanges the stored subject credential at the same `token_endpoint` using the gateway's own JWT-SVID as the client assertion. The supervisor then exchanges that intermediate token for the final upstream token using its own JWT-SVID as the client assertion. | Create provider instances for token-grant-only profiles with `--runtime-credentials`. This records an empty provider instance and makes the runtime-resolved credential source explicit: @@ -687,8 +694,9 @@ Token grant fields: | Field | Required | Behavior | |---|---|---| -| `grant_type` | No | `client_credentials` or `token_exchange`. Defaults to `client_credentials` for backward compatibility. | -| `token_endpoint` | Yes | OAuth2 token endpoint that accepts a SPIFFE JWT-SVID client assertion. Use `https://` unless the endpoint is loopback or a Kubernetes service DNS name such as `token-issuer.default.svc.cluster.local`. | +| `grant_type` | No | `client_credentials` or `token_exchange`. Defaults to `client_credentials`. | +| `assertion_source` | No | `spiffe` (default) or `openshell_gateway`. The gateway source is valid only with `client_credentials`; omit `jwt_svid_audience`, `client_assertion_type`, `requested_token_type`, and `subject_token`. | +| `token_endpoint` | Yes | OAuth2 token endpoint that accepts the selected client assertion. Use a canonical `https://` URL for `openshell_gateway`. SPIFFE modes also allow HTTP on loopback and Kubernetes service DNS names such as `token-issuer.default.svc.cluster.local`. | | `audience` | No | Resource audience requested from the token service. For `token_exchange`, this is the final exchange audience; the gateway intermediate exchange always uses the verified supervisor SVID subject as its audience. | | `jwt_svid_audience` | No | Audience used when requesting the JWT-SVID. When omitted, OpenShell derives an issuer-style audience from Keycloak token endpoint paths or falls back to the full token endpoint URL. | | `client_assertion_type` | No | OAuth2 `client_assertion_type` form value. Defaults to RFC 7523 `urn:ietf:params:oauth:client-assertion-type:jwt-bearer`. Set `urn:ietf:params:oauth:client-assertion-type:jwt-spiffe` when the token issuer expects the SPIFFE assertion type. | @@ -699,7 +707,7 @@ Token grant fields: | `subject_token.subject_token_type` | No | RFC 8693 `subject_token_type` for the stored subject token. Defaults to `urn:ietf:params:oauth:token-type:access_token`. | | `audience_overrides` | No | Endpoint-specific final-exchange `audience` and `scopes` overrides selected by host, port, and path. These overrides do not affect the gateway intermediate exchange. | -Token grants require the sandbox supervisor to have access to a SPIFFE Workload API socket. `token_exchange` also requires the gateway to have its own Workload API socket so it can present a gateway JWT-SVID during the intermediate exchange. They apply to HTTP traffic that the proxy can inspect. Token-grant profiles reject endpoints with `protocol: sql` or `tls: skip`, including when `allow_uninspected_credentials` is enabled, because those modes cannot inject the required HTTP credential. The token service must return a token value that is safe for HTTP header placement; malformed values are rejected before caching or header injection. +The `spiffe` assertion source requires the sandbox supervisor to have access to a SPIFFE Workload API socket. `token_exchange` also requires the gateway to have its own Workload API socket so it can present a gateway JWT-SVID during the intermediate exchange. Token grants apply to HTTP traffic that the proxy can inspect. Token-grant profiles reject endpoints with `protocol: sql` or `tls: skip`, including when `allow_uninspected_credentials` is enabled, because those modes cannot inject the required HTTP credential. The token service must return a token value that is safe for HTTP header placement; malformed values are rejected before caching or header injection. The gateway only brokers an intermediate token for a sandbox principal, and only when the requested provider is attached to that sandbox. It verifies the supervisor JWT-SVID issuer, audience, signature, and SPIFFE trust domain against the gateway's own JWT-SVID, then uses the verified supervisor SVID subject as the intermediate-token audience. diff --git a/docs/kubernetes/access-control.mdx b/docs/kubernetes/access-control.mdx index dbc5e99e48..c134a01e91 100644 --- a/docs/kubernetes/access-control.mdx +++ b/docs/kubernetes/access-control.mdx @@ -36,7 +36,7 @@ a gateway JWT. Dynamic provider token grants can use SPIFFE without changing supervisor-to-gateway authentication. Set `gatewayConfig.openshell.drivers.kubernetes.provider_spiffe_workload_api_socket_path` to mount the SPIFFE CSI Workload API socket into gateway and sandbox pods while keeping gateway-issued session JWTs for sandbox-specific RPCs. -Provider token grants require a SPIFFE implementation such as SPIRE and identities for the gateway and sandbox pods. The repository's local SPIRE overlay assigns sandbox IDs from the pod's `openshell.ai/sandbox-id` annotation, but the gateway validation path only requires the supervisor SVID to be valid and in the same SPIFFE trust domain as the gateway SVID. Provider profiles with `token_grant` metadata cause the sandbox supervisor to request JWT-SVIDs and exchange them for upstream OAuth2 access tokens. Token-exchange profiles also require a gateway SPIFFE identity because the gateway brokers the intermediate token exchange with its own JWT-SVID. +The default provider token grant uses SPIFFE. Deployments without SPIFFE can configure [gateway-signed provider client assertions](/how-it-works/gateways/configuration#provider-client-assertions). The repository's local SPIRE overlay assigns sandbox IDs from the pod's `openshell.ai/sandbox-id` annotation, but the gateway validation path only requires the supervisor SVID to be valid and in the same SPIFFE trust domain as the gateway SVID. For SPIFFE grant modes, the sandbox supervisor requests JWT-SVIDs and exchanges them for upstream OAuth2 access tokens. Token-exchange profiles also require a gateway SPIFFE identity because the gateway brokers the intermediate token exchange with its own JWT-SVID. The gateway verifies supervisor JWT-SVIDs with JWT bundles fetched from the SPIFFE Workload API, so intermediate token exchange does not require gateway access to the SPIRE OIDC discovery endpoint or its TLS CA. diff --git a/proto/openshell.proto b/proto/openshell.proto index b40ec68263..d4a0a32a83 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -548,6 +548,12 @@ service OpenShell { }; } + // Mint a short-lived client assertion for one attached provider credential. + rpc MintProviderClientAssertion(MintProviderClientAssertionRequest) + returns (MintProviderClientAssertionResponse) { + option (openshell.options.v1.authorization) = { auth_mode: "sandbox" }; + } + // Exchange a stored provider subject token for an intermediate token scoped // to the calling supervisor's SPIFFE identity. rpc ExchangeProviderSubjectToken(ExchangeProviderSubjectTokenRequest) @@ -2398,6 +2404,14 @@ enum ProviderCredentialTokenGrantType { PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_UNSPECIFIED = 0; PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_CLIENT_CREDENTIALS = 1; PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_TOKEN_EXCHANGE = 2; + // Wire discriminator for OAuth2 client_credentials with assertion_source + // openshell_gateway. Keep it distinct from CLIENT_CREDENTIALS so an older + // receiver cannot ignore the identity selection and use SPIFFE instead. + // Profile clients preserving this selection send gRPC metadata + // openshell-profile-openshell-gateway-assertion-source: 1 on list, get, + // import, and update calls. Before importing or updating, check the + // LintProviderProfilesResponse capability acknowledgment. + PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_OPENSHELL_CLIENT_CREDENTIALS = 3; } message ProviderCredentialTokenGrantSubjectToken { @@ -2449,6 +2463,11 @@ message ProviderCredentialTokenGrant { // OAuth2 requested_token_type. If omitted for token_exchange, OpenShell uses // urn:ietf:params:oauth:token-type:access_token. string requested_token_type = 10; + + // Output-only handle for auth_style placeholder. The gateway binds it to + // the sandbox, attachment epoch, provider identity and profile revision. + // Profile-authored values are ignored. + string workload_credential_handle = 11; } // Provider credential declaration. @@ -2475,6 +2494,11 @@ message ProviderProfileCredential { // Gateway-managed refresh and token-grant credentials cannot opt in because // they require identity-bearing handles for authorization-epoch revocation. bool stable_placeholder = 12; + // Output-only immutable provider identity derived by the gateway for a + // resolved token grant in an installed provider environment snapshot. + // Profile-authored values are ignored. Supervisors must use this value, + // never a workload-supplied identity, when requesting a gateway assertion. + string token_grant_provider_id = 13; } enum ProviderCredentialRefreshStrategy { @@ -2729,6 +2753,9 @@ message LintProviderProfilesResponse { // Clients enabling stable_placeholder check this before import or update, // since older gateways silently discard unknown credential fields. bool supports_stable_placeholder = 3; + // Clients must observe this before importing or updating a profile that + // selects the openshell_gateway assertion source. + bool supports_openshell_gateway_assertion_source = 4; } // Delete provider response. @@ -2875,6 +2902,28 @@ message ProviderEnvironmentSnapshot { map files = 7; } +message MintProviderClientAssertionRequest { + // The per-sandbox supervisor's authenticated, immutable sandbox ID. + string sandbox_id = 1; + string provider = 2; + string credential_key = 3; + // Expected value from the supervisor's snapshot. The gateway checks its + // current profile and operator registration before minting. + string token_endpoint = 4; + // Immutable provider ID from the supervisor's installed credential snapshot. + // The gateway refuses if the provider currently attached under the name has + // a different ID, even when the replacement is independently registered. + string expected_provider_id = 5; +} + +message MintProviderClientAssertionResponse { + string client_assertion = 1 [(openshell.options.v1.secret) = true]; + // Acknowledges the immutable provider identity that the gateway checked. + // The supervisor must compare this to the installed snapshot before using + // the assertion. An absent value identifies an unsupported gateway. + string bound_provider_id = 2; +} + message ExchangeProviderSubjectTokenRequest { // The sandbox ID. Must match the authenticated sandbox principal. string sandbox_id = 1; diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index a2a075091c..df196be33b 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -1005,8 +1005,10 @@ gateway before retrying the scrape. If `gatewayConfig.openshell.drivers.kubernetes.provider_spiffe_workload_api_socket_path` is set, the gateway should still render `[openshell.gateway.gateway_jwt]` and mount the `sandbox-jwt` Secret. -SPIRE is used by both the gateway and sandbox supervisors for dynamic provider -token grants. The gateway pod must mount the `spiffe-workload-api` CSI volume +For `client_credentials` grants with `assertion_source: openshell_gateway`, check the gateway signing configuration, +supervisor-to-gateway TLS, and the provider ID, credential key and token endpoint in +the operator registration. SPIRE is used by both the gateway and sandbox supervisors +for `token_exchange` grants. The gateway pod must mount the `spiffe-workload-api` CSI volume and set `OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET`; supervisor Pods must receive the matching Workload API socket from the Kubernetes driver config. The gateway verifies supervisor JWT-SVIDs from JWT bundles fetched through this @@ -1021,7 +1023,7 @@ kubectl -n openshell get configmap openshell-config -o yaml | grep provider_spif kubectl -n openshell get pod -l app.kubernetes.io/name=helm-chart -o jsonpath="{.items[*].spec.containers[*].env[?(@.name==\"OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET\")].value}{\"\n\"}" ``` -Sandbox pods using provider token grants should have an +Sandbox pods using SPIFFE provider token grants should have an `openshell.ai/sandbox-id` annotation, an `openshell.ai/managed-by=openshell` label, supervisor env vars `OPENSHELL_K8S_SA_TOKEN_FILE` and `OPENSHELL_PROVIDER_SPIFFE_WORKLOAD_API_SOCKET`, plus both the projected diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index 1265b8a610..78ccdad6ef 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -211,6 +211,8 @@ Use `profile describe` to inspect a definition's credential metadata, endpoints, Existing scripts can continue using `provider list-profiles` and `provider profile export/import/update/lint/delete`. These commands share the top-level handlers and preserve their arguments, output options, and workspace/global flags. Prefer `profile` when writing new commands. +For a provider using a gateway-signed client assertion, set `grant_type: client_credentials` and `assertion_source: openshell_gateway`. The CLI checks gateway support before importing or updating this selection. Upgrade every gateway replica, supervisor, and profile tool first; older tools can lose the selection outside the supported API. See the provider profiles and gateway configuration documentation for registration and rollback requirements. + ### List, inspect, update, delete Use `openshell sandbox provider status --help` and the attach, detach, and update help to find the installed version's wait options. Add `--wait` when the next step depends on a provider change taking effect. Without it, a successful command only confirms that the gateway saved the change. Save the returned `receipt_id` to check that same change later, and inspect the result for every selected sandbox. Credential refresh status confirms that OpenShell obtained credentials; provider status confirms that the sandbox applied them, activated the policy, and updated the environment for new processes. If the status is `superseded`, explain that a later change replaced the request and inspect that change separately. diff --git a/tests/support/unused_gateway_rpcs.rs b/tests/support/unused_gateway_rpcs.rs new file mode 100644 index 0000000000..6306acc879 --- /dev/null +++ b/tests/support/unused_gateway_rpcs.rs @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Unused unary methods shared by mock OpenShell gateways. + +#[macro_export] +macro_rules! unused_gateway_rpcs { + (sandbox_templates) => { + $crate::unused_gateway_rpcs! { + create_sandbox_template(CreateSandboxTemplateRequest) -> SandboxTemplateResponse; + get_sandbox_template(GetSandboxTemplateRequest) -> SandboxTemplateResponse; + list_sandbox_templates(ListSandboxTemplatesRequest) -> ListSandboxTemplatesResponse; + delete_sandbox_template(DeleteSandboxTemplateRequest) -> DeleteSandboxTemplateResponse; + } + }; + (provider_token_grants) => { + $crate::unused_gateway_rpcs! { + mint_provider_client_assertion(MintProviderClientAssertionRequest) -> MintProviderClientAssertionResponse; + exchange_provider_subject_token(ExchangeProviderSubjectTokenRequest) -> ExchangeProviderSubjectTokenResponse; + } + }; + // async_trait expands the impl before this macro, so emit its boxed signature. + ($( $method:ident($request:ident) -> $response:ident; )+) => { + $(fn $method<'life0, 'async_trait>( + &'life0 self, + _: tonic::Request, + ) -> std::pin::Pin, tonic::Status>, + > + Send + 'async_trait>> + where + 'life0: 'async_trait, + Self: 'async_trait, + { + Box::pin(async { Err(tonic::Status::unimplemented("unused")) }) + })+ + }; +}