Files
foxhunt/crates/config/src/vault.rs
jgrusewski 9c3d741a08 refactor: restructure repo — crates/, bin/, testing/ layout
Move 17 library crates into crates/, CLI binary into bin/fxt,
consolidate 10 test crates into testing/, split config crate
from deployment config files.

Root directory reduced from 38+ to ~17 directories.
All Cargo.toml paths and build.rs proto refs updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:56:00 +01:00

259 lines
8.3 KiB
Rust

//! HashiCorp Vault configuration for secure secret management.
//!
//! This module provides configuration structures for integrating with HashiCorp Vault
//! to securely manage secrets, API keys, and sensitive configuration data in the
//! Foxhunt trading system. Supports token-based authentication and namespace isolation.
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use std::fmt;
/// HashiCorp Vault configuration for secure secret storage.
///
/// Configures connection to HashiCorp Vault for retrieving sensitive
/// configuration data such as API keys, database passwords, and other
/// secrets. Supports Vault Enterprise features like namespaces.
///
/// # Security
///
/// The Vault token is wrapped in `SecretString` to prevent accidental
/// exposure in logs, debug output, or memory dumps. The token is automatically
/// zeroized when the config is dropped.
#[derive(Clone, Serialize, Deserialize)]
#[allow(clippy::module_name_repetitions)]
pub struct VaultConfig {
/// Vault server URL (e.g., "<https://vault.example.com:8200>")
pub url: String,
/// Vault authentication token for API access (securely stored)
#[serde(
serialize_with = "serialize_secret",
deserialize_with = "deserialize_secret"
)]
pub token: SecretString,
/// Mount path for the secrets engine (e.g., "secret/")
pub mount_path: String,
/// Vault namespace for multi-tenant deployments (Enterprise feature)
pub namespace: Option<String>,
}
/// Custom serializer for SecretString that prevents token exposure
fn serialize_secret<S>(_secret: &SecretString, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
// Serialize as redacted placeholder to prevent token exposure
serializer.serialize_str("***REDACTED***")
}
/// Custom deserializer for SecretString
fn deserialize_secret<'de, D>(deserializer: D) -> Result<SecretString, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Ok(SecretString::from(s))
}
impl fmt::Debug for VaultConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("VaultConfig")
.field("url", &self.url)
.field("token", &"***REDACTED***")
.field("mount_path", &self.mount_path)
.field("namespace", &self.namespace)
.finish()
}
}
impl Drop for VaultConfig {
fn drop(&mut self) {
// Explicitly zeroize the token when VaultConfig is dropped
// This ensures the secret is cleared from memory
// Note: SecretString already implements ZeroizeOnDrop, but we make it explicit
// for documentation purposes
}
}
impl VaultConfig {
/// Creates a new VaultConfig with the specified parameters.
///
/// # Security
///
/// The token is immediately wrapped in a `SecretString` to prevent exposure.
///
/// Consider using `from_env()` or loading from secure configuration
/// sources instead of passing plain strings.
pub fn new(url: String, token: String, mount_path: String) -> Self {
Self {
url,
token: SecretString::from(token),
mount_path,
namespace: None,
}
}
/// Sets the namespace for multi-tenant Vault deployments.
pub fn with_namespace(mut self, namespace: String) -> Self {
self.namespace = Some(namespace);
self
}
/// Gets a reference to the secret token (requires explicit exposure)
///
/// # Security
///
/// This method requires the caller to explicitly acknowledge they are
/// exposing the secret. Use only when necessary (e.g., when making
///
/// API calls to Vault) and ensure the exposed value is not logged
/// or stored in insecure locations.
pub const fn token(&self) -> &SecretString {
&self.token
}
/// Validates the vault configuration.
///
/// # Security
///
/// Validation checks length without exposing the token value.
///
/// # Errors
/// Returns error if the operation fails
pub fn validate(&self) -> Result<(), String> {
if self.url.is_empty() {
return Err("Vault URL cannot be empty".to_owned());
}
if self.token.expose_secret().is_empty() {
return Err("Vault token cannot be empty".to_owned());
}
if self.mount_path.is_empty() {
return Err("Vault mount path cannot be empty".to_owned());
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_config() -> VaultConfig {
VaultConfig::new(
"https://vault.example.com:8200".to_owned(),
"test-token-12345".to_owned(),
"secret/".to_owned(),
)
}
#[test]
fn test_vault_config_creation() {
let config = create_test_config();
assert_eq!(config.url, "https://vault.example.com:8200");
assert_eq!(config.mount_path, "secret/");
assert!(config.namespace.is_none());
}
#[test]
fn test_vault_config_with_namespace() {
let config = create_test_config().with_namespace("production".to_owned());
assert_eq!(config.namespace.as_deref(), Some("production"));
}
#[test]
fn test_vault_config_validation_success() {
let config = create_test_config();
assert!(config.validate().is_ok());
}
#[test]
fn test_vault_config_validation_empty_url() {
let mut config = create_test_config();
config.url = String::new();
assert!(config.validate().is_err());
assert_eq!(config.validate().unwrap_err(), "Vault URL cannot be empty");
}
#[test]
fn test_vault_config_validation_empty_token() {
let mut config = create_test_config();
config.token = SecretString::from(String::new());
assert!(config.validate().is_err());
assert_eq!(
config.validate().unwrap_err(),
"Vault token cannot be empty"
);
}
#[test]
fn test_vault_config_validation_empty_mount_path() {
let mut config = create_test_config();
config.mount_path = String::new();
assert!(config.validate().is_err());
assert_eq!(
config.validate().unwrap_err(),
"Vault mount path cannot be empty"
);
}
#[test]
fn test_vault_config_serialization() {
let config = create_test_config();
let serialized = serde_json::to_string(&config).unwrap();
// Token should be redacted in serialization
assert!(serialized.contains("***REDACTED***"));
assert!(!serialized.contains("test-token-12345"));
}
#[test]
fn test_vault_config_deserialization() {
let config = create_test_config();
let serialized = serde_json::to_string(&config).unwrap();
let deserialized: VaultConfig = serde_json::from_str(&serialized).unwrap();
assert_eq!(config.url, deserialized.url);
assert_eq!(config.mount_path, deserialized.mount_path);
}
#[test]
fn test_vault_config_clone() {
let config1 = create_test_config();
let config2 = config1.clone();
assert_eq!(config1.url, config2.url);
}
#[test]
fn test_vault_config_debug() {
let config = create_test_config();
let debug_output = format!("{:?}", config);
assert!(debug_output.contains("VaultConfig"));
assert!(debug_output.contains("***REDACTED***"));
assert!(!debug_output.contains("test-token-12345"));
}
#[test]
fn test_vault_config_namespace_none() {
let config = create_test_config();
assert!(config.namespace.is_none());
}
#[test]
fn test_vault_config_namespace_some() {
let config = create_test_config().with_namespace("dev".to_owned());
assert!(config.namespace.is_some());
assert_eq!(config.namespace.as_deref(), Some("dev"));
}
#[test]
fn test_vault_config_token_not_exposed() {
let config = create_test_config();
// Verify token accessor works
assert_eq!(config.token().expose_secret(), "test-token-12345");
}
#[test]
fn test_vault_config_token_redacted_in_display() {
let config = create_test_config();
let debug_str = format!("{:?}", config);
assert!(!debug_str.contains("test-token-12345"));
}
}