use std::fmt; use std::time::Duration; use remora_domain::OpaqueId; use reqwest::redirect::Policy; use reqwest::{Client, StatusCode}; use serde::{Deserialize, Serialize}; use crate::error::CredentialError; use crate::types::SecretMaterial; /// One non-destroyed KV v2 version observed via secret metadata. #[derive(Clone, Debug, Eq, PartialEq)] pub struct LiveVaultVersion { pub version: u64, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum EmptyPathFence { Created(u64), Occupied, } /// Vault KV v2 access for credential secret material. /// /// Topology and auth method are deployment choices. This client accepts a token /// (fixture root token or workload token after Kubernetes auth) and never logs /// request or response bodies that contain secret material. #[derive(Clone)] pub struct VaultConfig { pub address: String, pub token: String, /// KV v2 mount, e.g. `secret`. pub mount: String, /// Path prefix under the mount where this deployment may allocate secrets. pub path_prefix: String, pub timeout: Duration, } impl fmt::Debug for VaultConfig { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("VaultConfig") .field("address", &self.address) .field("mount", &self.mount) .field("path_prefix", &self.path_prefix) .field("timeout", &self.timeout) .field("token", &"REDACTED") .finish() } } impl VaultConfig { pub fn from_env() -> Result { let address = required_env("REMORA_VAULT_ADDR")?; let token = required_env("REMORA_VAULT_TOKEN")?; let full_path = required_env("REMORA_VAULT_PATH")?; let (mount, path_prefix) = split_vault_path(&full_path)?; Ok(Self { address, token, mount, path_prefix, timeout: Duration::from_secs(10), }) } #[must_use] pub fn with_timeout(mut self, timeout: Duration) -> Self { self.timeout = timeout; self } /// Allocate the opaque secret path for one credential version identity. #[must_use] pub fn secret_path_for(&self, credential_id: &OpaqueId, version_id: &OpaqueId) -> String { format!( "{}/versions/{}", self.credential_path_for(credential_id), version_id.as_str() ) } #[must_use] pub fn credential_path_for(&self, credential_id: &OpaqueId) -> String { if self.path_prefix.is_empty() { format!("credentials/{}", credential_id.as_str()) } else { format!( "{}/credentials/{}", self.path_prefix.trim_matches('/'), credential_id.as_str() ) } } } #[derive(Clone)] pub struct VaultClient { http: Client, config: VaultConfig, } impl fmt::Debug for VaultClient { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("VaultClient") .field("config", &self.config) .finish() } } impl VaultClient { pub fn new(config: VaultConfig) -> Result { // Disable redirects and ambient proxy discovery so X-Vault-Token and // secret write bodies cannot leave the configured Vault endpoint via // hop rewriting or HTTP_PROXY/HTTPS_PROXY injection. let http = Client::builder() .timeout(config.timeout) .redirect(Policy::none()) .no_proxy() .build() .map_err(|error| { CredentialError::Config(format!("failed to build vault HTTP client: {error}")) })?; Ok(Self { http, config }) } #[must_use] pub fn config(&self) -> &VaultConfig { &self.config } /// Create secret material and return the immutable KV v2 version number. /// /// The write uses CAS 0 so an existing intent-bound path is never overwritten. pub async fn put_version( &self, mount: &str, secret_path: &str, material: &SecretMaterial, ) -> Result { let url = format!( "{}/v1/{}/data/{}", self.config.address.trim_end_matches('/'), mount.trim_matches('/'), secret_path.trim_start_matches('/') ); let body = KvWriteRequest { data: material.clone(), options: KvWriteOptions { cas: 0 }, }; let response = self .http .post(&url) .header("X-Vault-Token", &self.config.token) .json(&body) .send() .await .map_err(map_transport)?; let status = response.status(); if !status.is_success() { let _ = response.bytes().await; return Err(map_http_status(status, "write secret version")); } let parsed: KvWriteResponse = response.json().await.map_err(map_transport)?; let version = parsed.data.and_then(|data| data.version).ok_or_else(|| { CredentialError::Config("vault write response omitted version".into()) })?; if version < 1 { return Err(CredentialError::Config( "vault returned non-positive secret version".into(), )); } Ok(version) } pub(crate) async fn fence_empty_path( &self, mount: &str, secret_path: &str, ) -> Result { let url = format!( "{}/v1/{}/data/{}", self.config.address.trim_end_matches('/'), mount.trim_matches('/'), secret_path.trim_start_matches('/') ); let response = self .http .post(&url) .header("X-Vault-Token", &self.config.token) .json(&FenceWriteRequest { data: FenceMarker { remora_write_fence: true, }, options: KvWriteOptions { cas: 0 }, }) .send() .await .map_err(map_transport)?; let status = response.status(); if status == StatusCode::BAD_REQUEST { let _ = response.bytes().await; return Ok(EmptyPathFence::Occupied); } if !status.is_success() { let _ = response.bytes().await; return Err(map_http_status(status, "fence empty secret path")); } let parsed: KvWriteResponse = response.json().await.map_err(map_transport)?; let version = parsed.data.and_then(|data| data.version).ok_or_else(|| { CredentialError::Config("vault fence response omitted version".into()) })?; if version < 1 { return Err(CredentialError::Config( "vault returned non-positive fence version".into(), )); } Ok(EmptyPathFence::Created(version)) } /// Read one exact KV v2 version. Missing or destroyed versions fail closed. pub async fn get_version( &self, mount: &str, secret_path: &str, vault_kv_version: u64, ) -> Result { let url = format!( "{}/v1/{}/data/{}?version={vault_kv_version}", self.config.address.trim_end_matches('/'), mount.trim_matches('/'), secret_path.trim_start_matches('/') ); let response = self .http .get(&url) .header("X-Vault-Token", &self.config.token) .send() .await .map_err(map_transport)?; let status = response.status(); if status == StatusCode::NOT_FOUND { let _ = response.bytes().await; return Err(CredentialError::Unresolvable); } if !status.is_success() { let _ = response.bytes().await; return Err(map_http_status(status, "read secret version")); } let parsed: KvReadResponse = response.json().await.map_err(map_transport)?; let data = parsed.data.ok_or(CredentialError::Unresolvable)?; let material = data.data.ok_or(CredentialError::Unresolvable)?; let reported = data .metadata .and_then(|meta| meta.version) .ok_or(CredentialError::Unresolvable)?; if reported != vault_kv_version { return Err(CredentialError::Unresolvable); } material.validate()?; Ok(material) } /// List non-destroyed KV v2 versions present at a path (for intent recovery). /// pub async fn list_live_versions( &self, mount: &str, secret_path: &str, ) -> Result, CredentialError> { let url = format!( "{}/v1/{}/metadata/{}", self.config.address.trim_end_matches('/'), mount.trim_matches('/'), secret_path.trim_start_matches('/') ); let response = self .http .get(&url) .header("X-Vault-Token", &self.config.token) .send() .await .map_err(map_transport)?; let status = response.status(); if status == StatusCode::NOT_FOUND { let _ = response.bytes().await; return Ok(Vec::new()); } if !status.is_success() { let _ = response.bytes().await; return Err(map_http_status(status, "list secret metadata")); } let parsed: KvMetadataResponse = response.json().await.map_err(map_transport)?; let versions = parsed .data .and_then(|data| data.versions) .unwrap_or_default(); let mut live = Vec::new(); for (key, meta) in versions { if meta.destroyed.unwrap_or(false) { continue; } let Ok(version) = key.parse::() else { continue; }; if version < 1 { continue; } live.push(LiveVaultVersion { version }); } live.sort_by_key(|entry| entry.version); Ok(live) } /// Irreversibly destroy one KV v2 version. pub async fn destroy_version( &self, mount: &str, secret_path: &str, vault_kv_version: u64, ) -> Result<(), CredentialError> { let url = format!( "{}/v1/{}/destroy/{}", self.config.address.trim_end_matches('/'), mount.trim_matches('/'), secret_path.trim_start_matches('/') ); let body = DestroyRequest { versions: vec![vault_kv_version], }; let response = self .http .post(&url) .header("X-Vault-Token", &self.config.token) .json(&body) .send() .await .map_err(map_transport)?; let status = response.status(); if !status.is_success() { let _ = response.bytes().await; return Err(map_http_status(status, "destroy secret version")); } let _ = response.bytes().await; Ok(()) } } #[derive(Serialize)] struct KvWriteRequest { data: SecretMaterial, options: KvWriteOptions, } #[derive(Serialize)] struct KvWriteOptions { cas: u8, } #[derive(Serialize)] struct FenceWriteRequest { data: FenceMarker, options: KvWriteOptions, } #[derive(Serialize)] struct FenceMarker { remora_write_fence: bool, } #[derive(Deserialize)] struct KvWriteResponse { data: Option, } #[derive(Deserialize)] struct KvWriteData { version: Option, } #[derive(Deserialize)] struct KvReadResponse { data: Option, } #[derive(Deserialize)] struct KvReadData { data: Option, metadata: Option, } #[derive(Deserialize)] struct KvMetadata { version: Option, } #[derive(Deserialize)] struct KvMetadataResponse { data: Option, } #[derive(Deserialize)] struct KvMetadataListData { versions: Option>, } #[derive(Deserialize)] struct KvVersionMeta { destroyed: Option, } #[derive(Serialize)] struct DestroyRequest { versions: Vec, } fn required_env(name: &str) -> Result { std::env::var(name) .ok() .filter(|value| !value.is_empty()) .ok_or_else(|| CredentialError::Config(format!("{name} is required"))) } /// Split a fixture path such as `secret/remora/lane-0` into mount + prefix. pub fn split_vault_path(full: &str) -> Result<(String, String), CredentialError> { let trimmed = full.trim().trim_matches('/'); if trimmed.is_empty() { return Err(CredentialError::Config( "REMORA_VAULT_PATH must include a mount".into(), )); } match trimmed.split_once('/') { Some((mount, prefix)) if !mount.is_empty() => { Ok((mount.to_owned(), prefix.trim_matches('/').to_owned())) } Some(_) | None => Ok((trimmed.to_owned(), String::new())), } } fn map_transport(_error: reqwest::Error) -> CredentialError { CredentialError::SecretStoreUnavailable } fn map_http_status(status: StatusCode, operation: &str) -> CredentialError { if status.is_server_error() || status == StatusCode::SERVICE_UNAVAILABLE { CredentialError::SecretStoreUnavailable } else if status == StatusCode::FORBIDDEN || status == StatusCode::UNAUTHORIZED { // Treat policy denials as unresolvable rather than leaking store details. CredentialError::Unresolvable } else if status == StatusCode::NOT_FOUND { CredentialError::Unresolvable } else { CredentialError::Config(format!( "vault {operation} failed with HTTP {}", status.as_u16() )) } }