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>
259 lines
8.3 KiB
Rust
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"));
|
|
}
|
|
}
|