// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 //! Credential driver backed by a Vault-compatible HTTP API. use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, Instant}; use openshell_core::VERSION; use openshell_core::proto::CredentialHandle; use openshell_core::proto::credentials::v1::{ DeleteCredentialRequest, DeleteCredentialResponse, GetCredentialDriverCapabilitiesRequest, GetCredentialDriverCapabilitiesResponse, ListCredentialsRequest, ListCredentialsResponse, ResolveCredentialRequest, ResolveCredentialsRequest, ResolveCredentialsResponse, ResolvedCredential, StoreCredentialRequest, StoreCredentialResponse, credential_driver_server::CredentialDriver, }; use openshell_core::{Error, Result as CoreResult}; use reqwest::{Certificate, StatusCode, Url}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tonic::{Request, Response, Status}; const DEFAULT_MOUNT: &str = "secret"; const DEFAULT_AUTH_METHOD: &str = "kubernetes"; const DEFAULT_KUBERNETES_AUTH_MOUNT: &str = "kubernetes"; const DEFAULT_SERVICE_ACCOUNT_TOKEN_PATH: &str = "/var/run/secrets/kubernetes.io/serviceaccount/token"; const DEFAULT_TIMEOUT_SECS: u64 = 10; const HANDLE_VERSION: &str = "v1"; const STORED_VALUE_KEY: &str = "value"; const OBJECT_ID_METADATA_KEY: &str = "openshell.storage_object_id"; pub struct VaultCredentialDriver { client: reqwest::Client, settings: VaultDriverSettings, cached_token: Arc>>, } struct CachedVaultToken { token: String, valid_until: Instant, } #[derive(Debug, Clone)] pub struct CredentialDriverService { driver: VaultCredentialDriver, } impl CredentialDriverService { #[must_use] pub fn new(driver: VaultCredentialDriver) -> Self { Self { driver } } } #[derive(Debug, Clone, PartialEq, Eq)] struct VaultDriverSettings { address: Url, ca_bundle: Option, mount: String, kv_version: KvVersion, auth: VaultAuthSettings, } #[derive(Debug, Clone, PartialEq, Eq)] enum VaultAuthSettings { Kubernetes { role: String, auth_mount: String, service_account_token_path: PathBuf, }, TokenFile { token_path: PathBuf, }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum KvVersion { V1, V2, } #[derive(Debug, Clone, Default, Deserialize)] #[serde(default, deny_unknown_fields)] struct VaultDriverConfig { address: Option, ca_bundle: Option, mount: Option, kv_version: Option, auth_method: Option, role: Option, kubernetes_auth_mount: Option, service_account_token_path: Option, token_path: Option, timeout_secs: Option, } #[derive(Debug, Clone, PartialEq, Eq)] struct VaultSecretReference { api_path: String, key: String, kv_version: KvVersion, } #[derive(Debug, Serialize)] struct KubernetesLoginRequest<'a> { role: &'a str, jwt: &'a str, } #[derive(Debug, Deserialize)] struct KubernetesLoginResponse { auth: Option, } #[derive(Debug, Deserialize)] struct KubernetesLoginAuth { client_token: String, #[serde(default)] lease_duration: u64, } impl VaultCredentialDriver { pub const NAME: &'static str = "vault"; pub fn from_config(config: &toml::Table) -> CoreResult { let settings = VaultDriverSettings::from_table(config)?; let timeout_secs = timeout_secs(config)?; let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let mut client_builder = reqwest::Client::builder() .timeout(Duration::from_secs(timeout_secs)) // System HTTP proxies can turn an otherwise-local plaintext request // into a network request. Vault credentials must always go directly // to the configured backend. .no_proxy() // Vault API requests carry bootstrap identity, backend tokens, and // provider secrets. Never replay them to a redirect target. .redirect(reqwest::redirect::Policy::none()); if let Some(ca_bundle) = settings.ca_bundle.as_deref() { let pem = std::fs::read(ca_bundle).map_err(|err| { Error::config(format!( "failed to read Vault CA bundle '{}': {err}", ca_bundle.display() )) })?; let certificates = Certificate::from_pem_bundle(&pem).map_err(|err| { Error::config(format!( "failed to parse Vault CA bundle '{}': {err}", ca_bundle.display() )) })?; if certificates.is_empty() { return Err(Error::config(format!( "Vault CA bundle '{}' contains no certificates", ca_bundle.display() ))); } for certificate in certificates { client_builder = client_builder.add_root_certificate(certificate); } } let client = client_builder.build().map_err(|err| { Error::config(format!( "failed to configure vault credential driver: {err}" )) })?; Ok(Self { client, settings, cached_token: Arc::new(tokio::sync::Mutex::new(None)), }) } pub async fn store_credential( &self, request: StoreCredentialRequest, ) -> Result { let object_id = if let Some(existing_handle) = request.existing_handle.as_ref() { object_id_from_handle(existing_handle, &request.provider_id)? } else { requested_object_id(&request.object_id, &request.provider_id)?.to_string() }; let logical_path = if let Some(existing_handle) = request.existing_handle.as_ref() { Self::logical_path_from_handle(existing_handle)? } else { managed_secret_path( &request.workspace, &request.provider_id, &request.provider, &request.credential_key, &object_id, ) }; validate_secret_path(&logical_path).map_err(Status::invalid_argument)?; validate_managed_secret_path( &request.workspace, &request.provider_id, &request.provider, &request.credential_key, &object_id, &logical_path, )?; let token = self.auth_token().await?; let reference = VaultSecretReference { api_path: api_path_for_reference( &self.settings.mount, self.settings.kv_version, &logical_path, ), key: STORED_VALUE_KEY.to_string(), kv_version: self.settings.kv_version, }; self.store_secret_value(&reference, &request.value, &token) .await?; Ok(CredentialHandle { driver: Self::NAME.to_string(), handle: format!("{HANDLE_VERSION}:{logical_path}"), metadata: std::collections::HashMap::from([( OBJECT_ID_METADATA_KEY.to_string(), object_id, )]), }) } pub async fn delete_credential(&self, request: DeleteCredentialRequest) -> Result<(), Status> { let handle = Self::handle_from_request("delete", request.handle)?; let logical_path = Self::logical_path_from_handle(&handle)?; let object_id = object_id_from_handle(&handle, &request.provider_id)?; validate_managed_secret_path( &request.workspace, &request.provider_id, &request.provider, &request.credential_key, &object_id, &logical_path, )?; let token = self.auth_token().await?; let api_path = delete_api_path_for_reference( &self.settings.mount, self.settings.kv_version, &logical_path, ); self.delete_secret_value(&api_path, &token).await } pub async fn resolve_credentials( &self, requests: Vec, ) -> Result, Status> { let mut resolved_requests = Vec::with_capacity(requests.len()); for request in requests { let handle = Self::handle_from_request(&request.request_id, request.handle)?; let logical_path = Self::logical_path_from_handle(&handle)?; let object_id = object_id_from_handle(&handle, &request.provider_id)?; validate_managed_secret_path( &request.workspace, &request.provider_id, &request.provider, &request.credential_key, &object_id, &logical_path, )?; let reference = VaultSecretReference { api_path: api_path_for_reference( &self.settings.mount, self.settings.kv_version, &logical_path, ), key: STORED_VALUE_KEY.to_string(), kv_version: self.settings.kv_version, }; resolved_requests.push((request.request_id, reference)); } let token = self.auth_token().await?; let futures = resolved_requests .into_iter() .map(|(request_id, reference)| { let token = token.clone(); async move { let value = self.resolve_secret_value(&reference, &token).await?; Ok::<_, Status>(ResolvedCredential { request_id, value, expiration_time: None, }) } }); futures::future::try_join_all(futures).await } fn handle_from_request( request_id: &str, handle: Option, ) -> Result { handle.ok_or_else(|| { Status::invalid_argument(format!( "vault credential request '{request_id}' is missing handle" )) }) } fn logical_path_from_handle(handle: &CredentialHandle) -> Result { let logical_path = handle .handle .strip_prefix(&format!("{HANDLE_VERSION}:")) .ok_or_else(|| Status::invalid_argument("vault credential handle is malformed"))?; validate_secret_path(logical_path).map_err(Status::invalid_argument)?; Ok(logical_path.to_string()) } async fn auth_token(&self) -> Result { match &self.settings.auth { VaultAuthSettings::TokenFile { token_path } => { read_secret_file(token_path, "Vault token file").await } VaultAuthSettings::Kubernetes { role, auth_mount, service_account_token_path, } => { let mut cache = self.cached_token.lock().await; if let Some(cached) = cache.as_ref() && Instant::now() < cached.valid_until { return Ok(cached.token.clone()); } let jwt = read_secret_file( service_account_token_path, "Kubernetes service account token", ) .await?; let (token, lease_duration) = self.login_kubernetes(role, auth_mount, &jwt).await?; if lease_duration > Duration::ZERO { let ttl = lease_duration.mul_f64(0.8); *cache = Some(CachedVaultToken { token: token.clone(), valid_until: Instant::now() + ttl, }); } Ok(token) } } } async fn login_kubernetes( &self, role: &str, auth_mount: &str, jwt: &str, ) -> Result<(String, Duration), Status> { let path = format!("auth/{auth_mount}/login"); let url = self.url_for_path(&path)?; let response = self .client .post(url) .json(&KubernetesLoginRequest { role, jwt }) .send() .await .map_err(|err| { Status::unavailable(format!("Vault Kubernetes auth request failed: {err}")) })?; let status = response.status(); if !status.is_success() { return Err(vault_auth_status(status)); } let body = response .json::() .await .map_err(|_| { Status::failed_precondition("Vault Kubernetes auth returned invalid JSON") })?; let (token, lease_duration) = body .auth .map(|auth| (auth.client_token, auth.lease_duration)) .unwrap_or_default(); let token = token.trim().to_string(); if token.is_empty() { return Err(Status::failed_precondition( "Vault Kubernetes auth returned an empty client token", )); } Ok((token, Duration::from_secs(lease_duration))) } async fn resolve_secret_value( &self, reference: &VaultSecretReference, token: &str, ) -> Result { let url = self.url_for_path(&reference.api_path)?; let response = self .client .get(url) .header("X-Vault-Token", token) .send() .await .map_err(|err| { Status::unavailable(format!( "Vault secret read failed for path '{}': {err}", reference.api_path )) })?; let status = response.status(); if !status.is_success() { return Err(vault_secret_status(status, &reference.api_path)); } let body = response.json::().await.map_err(|_| { Status::failed_precondition(format!( "Vault secret path '{}' returned invalid JSON", reference.api_path )) })?; extract_secret_value(&body, reference) } async fn store_secret_value( &self, reference: &VaultSecretReference, value: &str, token: &str, ) -> Result<(), Status> { let url = self.url_for_path(&reference.api_path)?; let body = match reference.kv_version { KvVersion::V1 => serde_json::json!({ &reference.key: value }), KvVersion::V2 => serde_json::json!({ "data": { &reference.key: value } }), }; let response = self .client .post(url) .header("X-Vault-Token", token) .json(&body) .send() .await .map_err(|err| { Status::unavailable(format!( "Vault secret write failed for path '{}': {err}", reference.api_path )) })?; let status = response.status(); if status.is_success() { Ok(()) } else { Err(vault_secret_status(status, &reference.api_path)) } } async fn delete_secret_value(&self, api_path: &str, token: &str) -> Result<(), Status> { let url = self.url_for_path(api_path)?; let response = self .client .delete(url) .header("X-Vault-Token", token) .send() .await .map_err(|err| { Status::unavailable(format!( "Vault secret delete failed for path '{api_path}': {err}" )) })?; let status = response.status(); if status.is_success() || status == StatusCode::NOT_FOUND { Ok(()) } else { Err(vault_secret_status(status, api_path)) } } fn url_for_path(&self, path: &str) -> Result { self.settings .address .join(&format!("v1/{path}")) .map_err(|err| Status::internal(format!("failed to build Vault URL: {err}"))) } } impl std::fmt::Debug for VaultCredentialDriver { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("VaultCredentialDriver") .field("settings", &self.settings) .finish_non_exhaustive() } } impl Clone for VaultCredentialDriver { fn clone(&self) -> Self { Self { client: self.client.clone(), settings: self.settings.clone(), cached_token: self.cached_token.clone(), } } } #[tonic::async_trait] impl CredentialDriver for CredentialDriverService { async fn get_capabilities( &self, request: Request, ) -> Result, Status> { let capabilities = GetCredentialDriverCapabilitiesResponse { driver_name: VaultCredentialDriver::NAME.to_string(), driver_version: VERSION.to_string(), backend_kind: VaultCredentialDriver::NAME.to_string(), supports_list: false, supports_expires_at: false, extension: Some(openshell_core::extension_protocol::extension_metadata( openshell_core::extension_protocol::ExtensionFamily::Credentials, "openshell/vault", VERSION, [], )), }; openshell_core::extension_protocol::validate_gateway_metadata( openshell_core::extension_protocol::ExtensionFamily::Credentials, VaultCredentialDriver::NAME, capabilities.extension.as_ref(), request.into_inner().gateway, ) .map_err(|error| Status::failed_precondition(error.to_string()))?; Ok(Response::new(capabilities)) } async fn store_credential( &self, request: Request, ) -> Result, Status> { let handle = self.driver.store_credential(request.into_inner()).await?; Ok(Response::new(StoreCredentialResponse { handle: Some(handle), })) } async fn delete_credential( &self, request: Request, ) -> Result, Status> { self.driver.delete_credential(request.into_inner()).await?; Ok(Response::new(DeleteCredentialResponse {})) } async fn resolve_credentials( &self, request: Request, ) -> Result, Status> { let credentials = self .driver .resolve_credentials(request.into_inner().credentials) .await?; Ok(Response::new(ResolveCredentialsResponse { credentials })) } async fn list_credentials( &self, _request: Request, ) -> Result, Status> { Err(Status::unimplemented( "vault credential driver does not support listing credentials", )) } } impl VaultDriverSettings { fn from_table(config: &toml::Table) -> CoreResult { let config: VaultDriverConfig = toml::Value::Table(config.clone()) .try_into() .map_err(|err| { Error::config(format!( "invalid [openshell.credential_drivers.vault]: {err}" )) })?; let address = config .address .as_deref() .ok_or_else(|| { Error::config("[openshell.credential_drivers.vault] address is required") }) .and_then(vault_address)?; if config.ca_bundle.is_some() && address.scheme() != "https" { return Err(Error::config( "[openshell.credential_drivers.vault] ca_bundle requires an https address", )); } let mount = config .mount .as_deref() .map_or_else(|| Ok(DEFAULT_MOUNT.to_string()), mount_config)?; let kv_version = config .kv_version .as_deref() .map_or_else(|| Ok(KvVersion::V2), KvVersion::parse_config)?; let auth_method = config .auth_method .as_deref() .unwrap_or(DEFAULT_AUTH_METHOD) .trim(); let auth = match auth_method { "kubernetes" => { if config.token_path.is_some() { return Err(Error::config( "[openshell.credential_drivers.vault] token_path requires auth_method = 'token_file'", )); } let role = config.role.as_deref().ok_or_else(|| { Error::config( "[openshell.credential_drivers.vault] role is required for auth_method = 'kubernetes'", ) })?; let role = trimmed_config_string("role", role)?.to_string(); let auth_mount = config.kubernetes_auth_mount.as_deref().map_or_else( || Ok(DEFAULT_KUBERNETES_AUTH_MOUNT.to_string()), |mount| path_config("kubernetes_auth_mount", mount), )?; let service_account_token_path = config .service_account_token_path .unwrap_or_else(|| PathBuf::from(DEFAULT_SERVICE_ACCOUNT_TOKEN_PATH)); VaultAuthSettings::Kubernetes { role, auth_mount, service_account_token_path, } } "token_file" => { if config.role.is_some() || config.kubernetes_auth_mount.is_some() || config.service_account_token_path.is_some() { return Err(Error::config( "[openshell.credential_drivers.vault] Kubernetes auth fields require auth_method = 'kubernetes'", )); } let token_path = config.token_path.ok_or_else(|| { Error::config( "[openshell.credential_drivers.vault] token_path is required for auth_method = 'token_file'", ) })?; VaultAuthSettings::TokenFile { token_path } } other => { return Err(Error::config(format!( "[openshell.credential_drivers.vault] auth_method must be 'kubernetes' or 'token_file', got '{other}'" ))); } }; Ok(Self { address, ca_bundle: config.ca_bundle, mount, kv_version, auth, }) } } impl KvVersion { fn parse_config(value: &str) -> CoreResult { match trimmed_config_string("kv_version", value)? { "1" => Ok(Self::V1), "2" => Ok(Self::V2), other => Err(Error::config(format!( "[openshell.credential_drivers.vault] kv_version must be '1' or '2', got '{other}'" ))), } } } fn vault_address(value: &str) -> CoreResult { let value = trimmed_config_string("address", value)?; let mut url = Url::parse(value).map_err(|_| { Error::config("[openshell.credential_drivers.vault] address must be an absolute URL") })?; if !matches!(url.scheme(), "http" | "https") { return Err(Error::config( "[openshell.credential_drivers.vault] address must use http or https", )); } if url.host().is_none() { return Err(Error::config( "[openshell.credential_drivers.vault] address must include a host", )); } if url.scheme() == "http" && !vault_host_is_loopback(&url) { return Err(Error::config( "[openshell.credential_drivers.vault] address must use https unless the host is loopback", )); } if !url.username().is_empty() || url.password().is_some() { return Err(Error::config( "[openshell.credential_drivers.vault] address must not include credentials", )); } if url.query().is_some() || url.fragment().is_some() { return Err(Error::config( "[openshell.credential_drivers.vault] address must not include query or fragment", )); } if !url.path().ends_with('/') { let path = format!("{}/", url.path().trim_end_matches('/')); url.set_path(&path); } Ok(url) } fn vault_host_is_loopback(url: &Url) -> bool { match url.host() { Some(url::Host::Ipv4(address)) => address.is_loopback(), Some(url::Host::Ipv6(address)) => address.is_loopback(), Some(url::Host::Domain(domain)) => domain.eq_ignore_ascii_case("localhost"), None => false, } } fn timeout_secs(table: &toml::Table) -> CoreResult { let Some(value) = table.get("timeout_secs") else { return Ok(DEFAULT_TIMEOUT_SECS); }; let timeout = value.as_integer().ok_or_else(|| { Error::config( "[openshell.credential_drivers.vault] timeout_secs must be a positive integer", ) })?; if timeout <= 0 { return Err(Error::config( "[openshell.credential_drivers.vault] timeout_secs must be a positive integer", )); } u64::try_from(timeout).map_err(|_| { Error::config("[openshell.credential_drivers.vault] timeout_secs is too large") }) } fn mount_config(value: &str) -> CoreResult { path_config("mount", value) } fn path_config(field_name: &str, value: &str) -> CoreResult { let value = trimmed_config_string(field_name, value)?; validate_secret_path(value).map_err(|message| { Error::config(format!( "[openshell.credential_drivers.vault] {field_name} {message}" )) })?; Ok(value.to_string()) } fn trimmed_config_string<'a>(field_name: &str, value: &'a str) -> CoreResult<&'a str> { let trimmed = value.trim(); if trimmed.is_empty() { return Err(Error::config(format!( "[openshell.credential_drivers.vault] {field_name} must not be empty" ))); } if trimmed.len() != value.len() { return Err(Error::config(format!( "[openshell.credential_drivers.vault] {field_name} must not contain leading or trailing whitespace" ))); } Ok(trimmed) } fn validate_secret_path(value: &str) -> Result<(), &'static str> { if value.is_empty() { return Err("must not be empty"); } if value.len() > 1024 { return Err("must be 1024 bytes or fewer"); } if value.starts_with('/') || value.ends_with('/') { return Err("must be a relative path without leading or trailing slash"); } if value.contains("//") { return Err("must not contain empty path segments"); } for segment in value.split('/') { if matches!(segment, "." | "..") { return Err("must not contain '.' or '..' path segments"); } if !segment .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) { return Err("may only contain ASCII letters, digits, '-', '_', '.', and '/'"); } } Ok(()) } fn api_path_for_reference(mount: &str, kv_version: KvVersion, target: &str) -> String { match kv_version { KvVersion::V1 => { if target == mount || target.starts_with(&format!("{mount}/")) { target.to_string() } else { format!("{mount}/{target}") } } KvVersion::V2 => { let data_prefix = format!("{mount}/data/"); if target.starts_with(&data_prefix) { target.to_string() } else { let logical_path = target.strip_prefix(&format!("{mount}/")).unwrap_or(target); format!("{mount}/data/{logical_path}") } } } } fn delete_api_path_for_reference(mount: &str, kv_version: KvVersion, target: &str) -> String { match kv_version { KvVersion::V1 => api_path_for_reference(mount, kv_version, target), KvVersion::V2 => { let metadata_prefix = format!("{mount}/metadata/"); if target.starts_with(&metadata_prefix) { target.to_string() } else { let logical_path = target.strip_prefix(&format!("{mount}/")).unwrap_or(target); format!("{mount}/metadata/{logical_path}") } } } } fn managed_secret_path( workspace: &str, provider_id: &str, provider_name: &str, credential_key: &str, object_id: &str, ) -> String { let mut hasher = Sha256::new(); hasher.update(workspace.as_bytes()); hasher.update([0]); hasher.update(provider_id.as_bytes()); hasher.update([0]); hasher.update(provider_name.as_bytes()); hasher.update([0]); hasher.update(credential_key.as_bytes()); if object_id != provider_id { hasher.update([0]); hasher.update(object_id.as_bytes()); } let digest = hasher.finalize(); let hex = format!("{digest:x}"); format!("openshell/provider-credentials/{}", &hex[..40]) } fn validate_managed_secret_path( workspace: &str, provider_id: &str, provider_name: &str, credential_key: &str, object_id: &str, logical_path: &str, ) -> Result<(), Status> { let expected = managed_secret_path( workspace, provider_id, provider_name, credential_key, object_id, ); if logical_path == expected { return Ok(()); } Err(Status::invalid_argument(format!( "vault credential handle path does not match the managed path for provider credential '{credential_key}'" ))) } fn requested_object_id<'a>(object_id: &'a str, provider_id: &'a str) -> Result<&'a str, Status> { let object_id = if object_id.is_empty() { provider_id } else { object_id }; if object_id.trim() != object_id || object_id.is_empty() { return Err(Status::invalid_argument( "vault credential object_id must not be empty or contain surrounding whitespace", )); } Ok(object_id) } fn object_id_from_handle(handle: &CredentialHandle, provider_id: &str) -> Result { requested_object_id( handle .metadata .get(OBJECT_ID_METADATA_KEY) .map_or("", String::as_str), provider_id, ) .map(str::to_string) } async fn read_secret_file(path: &Path, description: &str) -> Result { let contents = tokio::fs::read_to_string(path).await.map_err(|err| { Status::unauthenticated(format!( "failed to read {description} '{}': {err}", path.display() )) })?; let value = contents.trim().to_string(); if value.is_empty() { return Err(Status::unauthenticated(format!( "{description} '{}' is empty", path.display() ))); } Ok(value) } fn vault_auth_status(status: StatusCode) -> Status { match status { StatusCode::UNAUTHORIZED => { Status::unauthenticated("Vault Kubernetes auth rejected the service account token") } StatusCode::FORBIDDEN => { Status::permission_denied("Vault Kubernetes auth denied the configured role") } other => Status::unavailable(format!("Vault Kubernetes auth returned HTTP {other}")), } } fn vault_secret_status(status: StatusCode, path: &str) -> Status { match status { StatusCode::UNAUTHORIZED => { Status::unauthenticated("Vault rejected the credential driver token") } StatusCode::FORBIDDEN => Status::permission_denied(format!( "Vault token is not allowed to read secret path '{path}'" )), StatusCode::NOT_FOUND => { Status::not_found(format!("Vault secret path '{path}' was not found")) } other => Status::unavailable(format!("Vault secret path '{path}' returned HTTP {other}")), } } fn extract_secret_value( body: &serde_json::Value, reference: &VaultSecretReference, ) -> Result { let data = body .get("data") .and_then(serde_json::Value::as_object) .ok_or_else(|| { Status::failed_precondition(format!( "Vault secret path '{}' response is missing data", reference.api_path )) })?; let fields = match reference.kv_version { KvVersion::V1 => data, KvVersion::V2 => data .get("data") .and_then(serde_json::Value::as_object) .ok_or_else(|| { Status::failed_precondition(format!( "Vault KV v2 secret path '{}' response is missing data.data", reference.api_path )) })?, }; let value = fields.get(&reference.key).ok_or_else(|| { Status::not_found(format!( "Vault secret path '{}' does not contain key '{}'", reference.api_path, reference.key )) })?; value.as_str().map(str::to_string).ok_or_else(|| { Status::failed_precondition(format!( "Vault secret path '{}' key '{}' is not a string", reference.api_path, reference.key )) }) } #[cfg(test)] mod tests { use std::net::SocketAddr; use std::sync::Arc; use openshell_core::proto::CredentialHandle; use rcgen::{CertificateParams, IsCa, KeyPair}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tonic::Code; use wiremock::matchers::{body_string_contains, header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use super::*; fn handle(value: &str) -> CredentialHandle { CredentialHandle { driver: "vault".to_string(), handle: value.to_string(), metadata: std::collections::HashMap::new(), } } fn table(values: &[(&str, toml::Value)]) -> toml::Table { values .iter() .map(|(key, value)| ((*key).to_string(), value.clone())) .collect() } fn token_file(token: &str) -> tempfile::NamedTempFile { let file = tempfile::NamedTempFile::new().unwrap(); std::fs::write(file.path(), token).unwrap(); file } #[tokio::test] async fn capabilities_reject_missing_gateway_metadata() { let token = token_file("dev-token"); let driver = VaultCredentialDriver::from_config(&table(&[ ( "address", toml::Value::String("http://127.0.0.1:8200".to_string()), ), ("auth_method", toml::Value::String("token_file".to_string())), ( "token_path", toml::Value::String(token.path().display().to_string()), ), ])) .unwrap(); let service = CredentialDriverService::new(driver); let error = CredentialDriver::get_capabilities( &service, Request::new(GetCredentialDriverCapabilitiesRequest::default()), ) .await .unwrap_err(); assert_eq!(error.code(), Code::FailedPrecondition); assert!( error .message() .contains("gateway did not provide protocol metadata") ); } fn test_ca() -> (rcgen::Certificate, KeyPair) { let mut params = CertificateParams::new(Vec::::new()).unwrap(); params.is_ca = IsCa::Ca(rcgen::BasicConstraints::Unconstrained); let key = KeyPair::generate().unwrap(); let certificate = params.self_signed(&key).unwrap(); (certificate, key) } async fn start_tls_server(response: String) -> (SocketAddr, String) { let (ca_certificate, ca_key) = test_ca(); let server_key = KeyPair::generate().unwrap(); let server_certificate = CertificateParams::new(vec!["localhost".to_string()]) .unwrap() .signed_by(&server_key, &ca_certificate, &ca_key) .unwrap(); let server_config = rustls::ServerConfig::builder() .with_no_client_auth() .with_single_cert( vec![server_certificate.der().clone()], rustls::pki_types::PrivateKeyDer::try_from(server_key.serialize_der()).unwrap(), ) .unwrap(); let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(server_config)); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); tokio::spawn(async move { let Ok((socket, _)) = listener.accept().await else { return; }; let Ok(mut tls) = acceptor.accept(socket).await else { return; }; let mut request = vec![0_u8; 4096]; let mut used = 0; loop { let Ok(read) = tls.read(&mut request[used..]).await else { return; }; used += read; if read == 0 || request[..used].windows(4).any(|bytes| bytes == b"\r\n\r\n") { break; } if used == request.len() { return; } } let _ = tls.write_all(response.as_bytes()).await; let _ = tls.flush().await; }); (address, ca_certificate.pem()) } fn ca_bundle_file(pem: &str) -> tempfile::NamedTempFile { let file = tempfile::NamedTempFile::new().unwrap(); std::fs::write(file.path(), pem).unwrap(); file } fn test_reference() -> VaultSecretReference { VaultSecretReference { api_path: "secret/data/test".to_string(), key: STORED_VALUE_KEY.to_string(), kv_version: KvVersion::V2, } } #[test] fn settings_parse_kubernetes_auth() { let settings = VaultDriverSettings::from_table(&table(&[ ( "address", toml::Value::String("https://vault:8200".to_string()), ), ("mount", toml::Value::String("team-secret".to_string())), ("kv_version", toml::Value::String("1".to_string())), ("auth_method", toml::Value::String("kubernetes".to_string())), ("role", toml::Value::String("openshell-gateway".to_string())), ])) .unwrap(); assert_eq!(settings.mount, "team-secret"); assert_eq!(settings.kv_version, KvVersion::V1); assert!(matches!( settings.auth, VaultAuthSettings::Kubernetes { .. } )); } #[test] fn settings_parse_token_file_auth() { let settings = VaultDriverSettings::from_table(&table(&[ ( "address", toml::Value::String("https://vault:8200".to_string()), ), ("auth_method", toml::Value::String("token_file".to_string())), ( "token_path", toml::Value::String("/run/secrets/vault-token".to_string()), ), ])) .unwrap(); assert!(matches!(settings.auth, VaultAuthSettings::TokenFile { .. })); assert_eq!(settings.kv_version, KvVersion::V2); } #[test] fn settings_reject_unknown_fields() { let err = VaultDriverSettings::from_table(&table(&[ ( "address", toml::Value::String("https://vault:8200".to_string()), ), ("auth_method", toml::Value::String("token_file".to_string())), ( "token_path", toml::Value::String("/run/secrets/vault-token".to_string()), ), ("token", toml::Value::String("literal-secret".to_string())), ])) .unwrap_err(); assert!(err.to_string().contains("unknown field")); } #[test] fn settings_reject_token_file_without_token_path() { let err = VaultDriverSettings::from_table(&table(&[ ( "address", toml::Value::String("https://vault:8200".to_string()), ), ("auth_method", toml::Value::String("token_file".to_string())), ])) .unwrap_err(); assert!(err.to_string().contains("token_path is required")); } #[test] fn settings_reject_non_loopback_http_address() { let err = vault_address("http://vault.vault.svc.cluster.local:8200").unwrap_err(); assert!(err.to_string().contains("must use https"), "{err}"); } #[test] fn settings_allow_loopback_http_addresses() { for address in [ "http://127.0.0.1:8200", "http://[::1]:8200", "http://localhost:8200", ] { vault_address(address).unwrap(); } } #[test] fn settings_reject_ca_bundle_for_http() { let err = VaultDriverSettings::from_table(&table(&[ ( "address", toml::Value::String("http://127.0.0.1:8200".to_string()), ), ( "ca_bundle", toml::Value::String("/etc/vault/ca.pem".to_string()), ), ("auth_method", toml::Value::String("token_file".to_string())), ( "token_path", toml::Value::String("/run/secrets/vault-token".to_string()), ), ])) .unwrap_err(); assert!(err.to_string().contains("ca_bundle requires an https")); } #[tokio::test] async fn https_uses_custom_ca_bundle() { let body = r#"{"data":{"data":{"value":"protected-secret"}}}"#; let response = format!( "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len() ); let (address, ca_pem) = start_tls_server(response).await; let ca_bundle = ca_bundle_file(&ca_pem); let token = token_file("dev-token"); let driver = VaultCredentialDriver::from_config(&table(&[ ( "address", toml::Value::String(format!("https://localhost:{}/", address.port())), ), ( "ca_bundle", toml::Value::String(ca_bundle.path().display().to_string()), ), ("auth_method", toml::Value::String("token_file".to_string())), ( "token_path", toml::Value::String(token.path().display().to_string()), ), ])) .unwrap(); let value = driver .resolve_secret_value(&test_reference(), "dev-token") .await .unwrap(); assert_eq!(value, "protected-secret"); } #[tokio::test] async fn https_rejects_invalid_ca() { let (address, _) = start_tls_server(String::new()).await; let (untrusted_ca, _) = test_ca(); let ca_bundle = ca_bundle_file(&untrusted_ca.pem()); let token = token_file("dev-token"); let driver = VaultCredentialDriver::from_config(&table(&[ ( "address", toml::Value::String(format!("https://localhost:{}/", address.port())), ), ( "ca_bundle", toml::Value::String(ca_bundle.path().display().to_string()), ), ("auth_method", toml::Value::String("token_file".to_string())), ( "token_path", toml::Value::String(token.path().display().to_string()), ), ])) .unwrap(); let err = driver .resolve_secret_value(&test_reference(), "dev-token") .await .unwrap_err(); assert_eq!(err.code(), Code::Unavailable); } #[tokio::test] async fn https_preserves_hostname_verification() { let (address, ca_pem) = start_tls_server(String::new()).await; let ca_bundle = ca_bundle_file(&ca_pem); let token = token_file("dev-token"); let driver = VaultCredentialDriver::from_config(&table(&[ ( "address", toml::Value::String(format!("https://127.0.0.1:{}/", address.port())), ), ( "ca_bundle", toml::Value::String(ca_bundle.path().display().to_string()), ), ("auth_method", toml::Value::String("token_file".to_string())), ( "token_path", toml::Value::String(token.path().display().to_string()), ), ])) .unwrap(); let err = driver .resolve_secret_value(&test_reference(), "dev-token") .await .unwrap_err(); assert_eq!(err.code(), Code::Unavailable); } #[tokio::test] async fn https_does_not_follow_redirect_downgrade() { let downgrade_target = MockServer::start().await; let location = format!("{}/stolen", downgrade_target.uri()); let response = format!( "HTTP/1.1 307 Temporary Redirect\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" ); let (address, ca_pem) = start_tls_server(response).await; let ca_bundle = ca_bundle_file(&ca_pem); let token = token_file("dev-token"); let driver = VaultCredentialDriver::from_config(&table(&[ ( "address", toml::Value::String(format!("https://localhost:{}/", address.port())), ), ( "ca_bundle", toml::Value::String(ca_bundle.path().display().to_string()), ), ("auth_method", toml::Value::String("token_file".to_string())), ( "token_path", toml::Value::String(token.path().display().to_string()), ), ])) .unwrap(); driver .login_kubernetes("openshell-gateway", "kubernetes", "gateway-jwt") .await .unwrap_err(); assert!( downgrade_target .received_requests() .await .unwrap() .is_empty() ); } #[test] fn loopback_http_bypasses_system_proxy() { let proxy_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let proxy_address = proxy_listener.local_addr().unwrap(); drop(proxy_listener); let output = std::process::Command::new(std::env::current_exe().unwrap()) .args([ "--exact", "tests::loopback_http_bypasses_system_proxy_child", "--ignored", "--nocapture", ]) .env("OPENSHELL_VAULT_PROXY_TEST_CHILD", "1") .env("HTTP_PROXY", format!("http://{proxy_address}")) .env("http_proxy", format!("http://{proxy_address}")) .env_remove("NO_PROXY") .env_remove("no_proxy") .env_remove("ALL_PROXY") .env_remove("all_proxy") .env_remove("REQUEST_METHOD") .output() .unwrap(); assert!( output.status.success(), "child test failed:\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); } #[tokio::test] #[ignore = "run by loopback_http_bypasses_system_proxy in an isolated process"] async fn loopback_http_bypasses_system_proxy_child() { if std::env::var_os("OPENSHELL_VAULT_PROXY_TEST_CHILD").is_none() { return; } let mock_server = MockServer::start().await; Mock::given(method("GET")) .and(path("/v1/secret/data/test")) .and(header("x-vault-token", "dev-token")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "data": {"data": {"value": "protected-secret"}} }))) .mount(&mock_server) .await; let token = token_file("dev-token"); let driver = VaultCredentialDriver::from_config(&table(&[ ( "address", toml::Value::String(format!("{}/", mock_server.uri())), ), ("auth_method", toml::Value::String("token_file".to_string())), ( "token_path", toml::Value::String(token.path().display().to_string()), ), ])) .unwrap(); let value = driver .resolve_secret_value(&test_reference(), "dev-token") .await .unwrap(); assert_eq!(value, "protected-secret"); } #[test] fn api_path_builds_kv2_api_path_from_logical_path() { assert_eq!( api_path_for_reference( "secret", KvVersion::V2, "openshell/provider-credentials/abc" ), "secret/data/openshell/provider-credentials/abc" ); } #[test] fn delete_api_path_builds_kv2_metadata_path_from_logical_path() { assert_eq!( delete_api_path_for_reference( "secret", KvVersion::V2, "openshell/provider-credentials/abc" ), "secret/metadata/openshell/provider-credentials/abc" ); } #[test] fn handle_rejects_malformed_value() { let err = VaultCredentialDriver::logical_path_from_handle(&handle("providers/nvidia")) .unwrap_err(); assert_eq!(err.code(), Code::InvalidArgument); assert!(err.message().contains("malformed")); } #[test] fn handle_rejects_invalid_path() { let err = VaultCredentialDriver::logical_path_from_handle(&handle("v1:../providers/nvidia")) .unwrap_err(); assert_eq!(err.code(), Code::InvalidArgument); assert!(err.message().contains("path segments")); } #[test] fn handle_rejects_unexpected_managed_path() { let err = validate_managed_secret_path( "default", "prov-123", "nvidia-prod", "NVIDIA_API_KEY", "prov-123", "openshell/provider-credentials/other", ) .unwrap_err(); assert_eq!(err.code(), Code::InvalidArgument); assert!(err.message().contains("managed path")); } #[test] fn staged_paths_keep_provider_ownership_and_use_distinct_object_identity() { let committed = managed_secret_path( "default", "prov-123", "nvidia-prod", "NVIDIA_API_KEY", "prov-123", ); let staged = managed_secret_path( "default", "prov-123", "nvidia-prod", "NVIDIA_API_KEY", "refresh-456", ); assert_ne!(committed, staged); validate_managed_secret_path( "default", "prov-123", "nvidia-prod", "NVIDIA_API_KEY", "refresh-456", &staged, ) .unwrap(); assert!( validate_managed_secret_path( "default", "other-provider", "nvidia-prod", "NVIDIA_API_KEY", "refresh-456", &staged, ) .is_err() ); } #[tokio::test] async fn store_and_resolve_token_file_kv2_secret() { let mock_server = MockServer::start().await; let logical_path = managed_secret_path( "default", "prov-123", "nvidia-prod", "NVIDIA_API_KEY", "prov-123", ); let api_path = format!("/v1/secret/data/{logical_path}"); Mock::given(method("POST")) .and(path(api_path.as_str())) .and(header("x-vault-token", "dev-token")) .and(body_string_contains("nvapi-test")) .respond_with(ResponseTemplate::new(200)) .mount(&mock_server) .await; Mock::given(method("GET")) .and(path(api_path.as_str())) .and(header("x-vault-token", "dev-token")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "data": { "data": { "value": "nvapi-test" }, "metadata": { "version": 1 } } }))) .mount(&mock_server) .await; let token_file = token_file("dev-token\n"); let driver = VaultCredentialDriver::from_config(&table(&[ ("address", toml::Value::String(mock_server.uri())), ("auth_method", toml::Value::String("token_file".to_string())), ( "token_path", toml::Value::String(token_file.path().display().to_string()), ), ])) .unwrap(); let stored = driver .store_credential(StoreCredentialRequest { provider: "nvidia-prod".to_string(), credential_key: "NVIDIA_API_KEY".to_string(), value: "nvapi-test".to_string(), existing_handle: None, workspace: "default".to_string(), provider_id: "prov-123".to_string(), object_id: "prov-123".to_string(), }) .await .unwrap(); assert_eq!(stored.handle, format!("v1:{logical_path}")); let resolved = driver .resolve_credentials(vec![ResolveCredentialRequest { request_id: "credential-0".to_string(), provider: "nvidia-prod".to_string(), credential_key: "NVIDIA_API_KEY".to_string(), handle: Some(stored), workspace: "default".to_string(), provider_id: "prov-123".to_string(), }]) .await .unwrap(); assert_eq!(resolved[0].value, "nvapi-test"); } #[tokio::test] async fn store_with_existing_handle_reuses_logical_path() { let mock_server = MockServer::start().await; let logical_path = managed_secret_path( "default", "prov-123", "nvidia-prod", "NVIDIA_API_KEY", "prov-123", ); Mock::given(method("POST")) .and(path(format!("/v1/secret/data/{logical_path}"))) .and(header("x-vault-token", "dev-token")) .and(body_string_contains("updated-secret")) .respond_with(ResponseTemplate::new(200)) .mount(&mock_server) .await; let token_file = token_file("dev-token\n"); let driver = VaultCredentialDriver::from_config(&table(&[ ("address", toml::Value::String(mock_server.uri())), ("auth_method", toml::Value::String("token_file".to_string())), ( "token_path", toml::Value::String(token_file.path().display().to_string()), ), ])) .unwrap(); let stored = driver .store_credential(StoreCredentialRequest { provider: "nvidia-prod".to_string(), credential_key: "NVIDIA_API_KEY".to_string(), value: "updated-secret".to_string(), existing_handle: Some(handle(&format!("v1:{logical_path}"))), workspace: "default".to_string(), provider_id: "prov-123".to_string(), object_id: "prov-123".to_string(), }) .await .unwrap(); assert_eq!(stored.handle, format!("v1:{logical_path}")); } #[tokio::test] async fn delete_token_file_kv2_secret() { let mock_server = MockServer::start().await; let logical_path = managed_secret_path( "default", "prov-123", "nvidia-prod", "NVIDIA_API_KEY", "prov-123", ); Mock::given(method("DELETE")) .and(path(format!("/v1/secret/metadata/{logical_path}"))) .and(header("x-vault-token", "dev-token")) .respond_with(ResponseTemplate::new(204)) .mount(&mock_server) .await; let token_file = token_file("dev-token\n"); let driver = VaultCredentialDriver::from_config(&table(&[ ("address", toml::Value::String(mock_server.uri())), ("auth_method", toml::Value::String("token_file".to_string())), ( "token_path", toml::Value::String(token_file.path().display().to_string()), ), ])) .unwrap(); driver .delete_credential(DeleteCredentialRequest { provider: "nvidia-prod".to_string(), credential_key: "NVIDIA_API_KEY".to_string(), handle: Some(handle(&format!("v1:{logical_path}"))), workspace: "default".to_string(), provider_id: "prov-123".to_string(), }) .await .unwrap(); } #[tokio::test] async fn resolve_kubernetes_auth_kv2_secret() { let mock_server = MockServer::start().await; let logical_path = managed_secret_path( "test-workspace", "test-provider-id", "github-prod", "GITHUB_TOKEN", "test-provider-id", ); Mock::given(method("POST")) .and(path("/v1/auth/kubernetes/login")) .and(body_string_contains("openshell-gateway")) .and(body_string_contains("jwt-test")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "auth": { "client_token": "bao-token" } }))) .mount(&mock_server) .await; Mock::given(method("GET")) .and(path(format!("/v1/secret/data/{logical_path}"))) .and(header("x-vault-token", "bao-token")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "data": { "data": { "value": "ghp-test" } } }))) .mount(&mock_server) .await; let jwt_file = token_file("jwt-test\n"); let driver = VaultCredentialDriver::from_config(&table(&[ ("address", toml::Value::String(mock_server.uri())), ("auth_method", toml::Value::String("kubernetes".to_string())), ("role", toml::Value::String("openshell-gateway".to_string())), ( "service_account_token_path", toml::Value::String(jwt_file.path().display().to_string()), ), ])) .unwrap(); let resolved = driver .resolve_credentials(vec![ResolveCredentialRequest { request_id: "credential-0".to_string(), provider: "github-prod".to_string(), credential_key: "GITHUB_TOKEN".to_string(), handle: Some(handle(&format!("v1:{logical_path}"))), workspace: "test-workspace".to_string(), provider_id: "test-provider-id".to_string(), }]) .await .unwrap(); assert_eq!(resolved[0].value, "ghp-test"); } #[tokio::test] async fn resolve_maps_missing_key() { let mock_server = MockServer::start().await; let logical_path = managed_secret_path( "default", "prov-123", "nvidia-prod", "NVIDIA_API_KEY", "prov-123", ); Mock::given(method("GET")) .and(path(format!("/v1/secret/data/{logical_path}"))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "data": { "data": {} } }))) .mount(&mock_server) .await; let token_file = token_file("dev-token\n"); let driver = VaultCredentialDriver::from_config(&table(&[ ("address", toml::Value::String(mock_server.uri())), ("auth_method", toml::Value::String("token_file".to_string())), ( "token_path", toml::Value::String(token_file.path().display().to_string()), ), ])) .unwrap(); let err = driver .resolve_credentials(vec![ResolveCredentialRequest { request_id: "credential-0".to_string(), provider: "nvidia-prod".to_string(), credential_key: "NVIDIA_API_KEY".to_string(), handle: Some(handle(&format!("v1:{logical_path}"))), workspace: "default".to_string(), provider_id: "prov-123".to_string(), }]) .await .unwrap_err(); assert_eq!(err.code(), Code::NotFound); assert!(err.message().contains("does not contain key")); } #[tokio::test] async fn resolve_maps_permission_denied() { let mock_server = MockServer::start().await; let logical_path = managed_secret_path( "default", "prov-123", "nvidia-prod", "NVIDIA_API_KEY", "prov-123", ); Mock::given(method("GET")) .and(path(format!("/v1/secret/data/{logical_path}"))) .respond_with(ResponseTemplate::new(403)) .mount(&mock_server) .await; let token_file = token_file("dev-token\n"); let driver = VaultCredentialDriver::from_config(&table(&[ ("address", toml::Value::String(mock_server.uri())), ("auth_method", toml::Value::String("token_file".to_string())), ( "token_path", toml::Value::String(token_file.path().display().to_string()), ), ])) .unwrap(); let err = driver .resolve_credentials(vec![ResolveCredentialRequest { request_id: "credential-0".to_string(), provider: "nvidia-prod".to_string(), credential_key: "NVIDIA_API_KEY".to_string(), handle: Some(handle(&format!("v1:{logical_path}"))), workspace: "default".to_string(), provider_id: "prov-123".to_string(), }]) .await .unwrap_err(); assert_eq!(err.code(), Code::PermissionDenied); } }