Skip to main content

hyperi_rustlib/secrets/
types.rs

1// Project:   hyperi-rustlib
2// File:      src/secrets/types.rs
3// Purpose:   Secrets type definitions
4// Language:  Rust
5//
6// License:   FSL-1.1-ALv2
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Type definitions for the secrets module.
10
11use std::collections::HashMap;
12use std::path::PathBuf;
13use std::time::SystemTime;
14
15use serde::{Deserialize, Serialize};
16
17use super::error::SecretsResult;
18
19/// Main configuration for the secrets manager.
20#[derive(Debug, Clone, Default, Serialize, Deserialize)]
21#[serde(default)]
22pub struct SecretsConfig {
23    /// Cache configuration.
24    pub cache: CacheConfig,
25
26    /// OpenBao/Vault configuration.
27    #[cfg(feature = "secrets-vault")]
28    pub openbao: Option<super::OpenBaoConfig>,
29
30    /// AWS Secrets Manager configuration.
31    #[cfg(feature = "secrets-aws")]
32    pub aws: Option<super::AwsConfig>,
33
34    /// Placeholder for vault config when feature disabled.
35    #[cfg(not(feature = "secrets-vault"))]
36    #[serde(skip)]
37    pub openbao: Option<()>,
38
39    /// Placeholder for AWS config when feature disabled.
40    #[cfg(not(feature = "secrets-aws"))]
41    #[serde(skip)]
42    pub aws: Option<()>,
43
44    /// Named secret sources.
45    pub sources: HashMap<String, SecretSource>,
46}
47
48impl SecretsConfig {
49    /// Load from the config cascade under the `secrets` key.
50    #[must_use]
51    pub fn from_cascade() -> Self {
52        #[cfg(feature = "config")]
53        {
54            if let Some(cfg) = crate::config::try_get()
55                && let Ok(secrets) = cfg.unmarshal_key_registered::<Self>("secrets")
56            {
57                return secrets;
58            }
59        }
60        Self::default()
61    }
62}
63
64/// Cache configuration.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(default)]
67pub struct CacheConfig {
68    /// Enable caching.
69    pub enabled: bool,
70
71    /// Cache directory path.
72    pub directory: Option<PathBuf>,
73
74    /// Cache TTL in seconds (how long cached secrets are considered fresh).
75    pub ttl_secs: u64,
76
77    /// Stale cache grace period in seconds (how long to use expired cache on provider failure).
78    pub stale_grace_secs: u64,
79
80    /// Background refresh interval in seconds.
81    pub refresh_interval_secs: u64,
82
83    /// Refresh jitter in seconds (randomize to avoid thundering herd).
84    pub refresh_jitter_secs: u64,
85
86    /// Reserved for future use. Cache is currently stored unencrypted.
87    /// If set, the value will be redacted in serialisation and debug output.
88    pub encryption_key: Option<crate::SensitiveString>,
89
90    /// Unix permission mode for the cache directory. Default `0o700`.
91    /// Set to `None` to skip chmod entirely -- required on backing
92    /// stores that reject `chmod`: S3-FUSE, root-squashed NFS, some
93    /// other network mounts. Operators are responsible for upstream
94    /// perms in that case.
95    pub dir_mode: Option<u32>,
96
97    /// Unix permission mode for cache files. Default `0o600`. `None`
98    /// skips chmod, see `dir_mode`.
99    pub file_mode: Option<u32>,
100}
101
102impl Default for CacheConfig {
103    fn default() -> Self {
104        Self {
105            enabled: true,
106            directory: None,             // Auto-detect
107            ttl_secs: 3600,              // 1 hour
108            stale_grace_secs: 86400,     // 24 hours
109            refresh_interval_secs: 1800, // 30 minutes
110            refresh_jitter_secs: 300,    // 5 minutes
111            encryption_key: None,
112            dir_mode: Some(0o700),
113            file_mode: Some(0o600),
114        }
115    }
116}
117
118/// Configuration for a secret source.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120#[serde(tag = "provider", rename_all = "snake_case")]
121pub enum SecretSource {
122    /// Load from local file.
123    File {
124        /// Path to the secret file.
125        path: String,
126    },
127
128    /// Load from OpenBao/Vault.
129    OpenBao {
130        /// Secret path in Vault (e.g., "secret/data/myapp/tls").
131        path: String,
132        /// Key within the secret (e.g., "certificate").
133        key: String,
134    },
135
136    /// Load from AWS Secrets Manager.
137    Aws {
138        /// Secret name or ARN.
139        secret_id: String,
140        /// Key within the JSON secret (optional for plaintext secrets).
141        key: Option<String>,
142    },
143}
144
145/// Value retrieved from a secrets provider.
146#[derive(Clone)]
147pub struct SecretValue {
148    /// The secret data (may be binary or text).
149    pub data: Vec<u8>,
150
151    /// When this secret was fetched.
152    pub fetched_at: SystemTime,
153
154    /// Metadata from the provider.
155    pub metadata: SecretMetadata,
156}
157
158impl std::fmt::Debug for SecretValue {
159    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        f.debug_struct("SecretValue")
161            .field("data", &"[REDACTED]")
162            .field("fetched_at", &self.fetched_at)
163            .field("metadata", &self.metadata)
164            .finish()
165    }
166}
167
168impl SecretValue {
169    /// Create a new secret value.
170    #[must_use]
171    pub fn new(data: Vec<u8>) -> Self {
172        Self {
173            data,
174            fetched_at: SystemTime::now(),
175            metadata: SecretMetadata::default(),
176        }
177    }
178
179    /// Create a new secret value with metadata.
180    #[must_use]
181    pub fn with_metadata(data: Vec<u8>, metadata: SecretMetadata) -> Self {
182        Self {
183            data,
184            fetched_at: SystemTime::now(),
185            metadata,
186        }
187    }
188
189    /// Get the secret as a UTF-8 string.
190    ///
191    /// # Errors
192    ///
193    /// Returns an error if the data is not valid UTF-8.
194    pub fn as_str(&self) -> SecretsResult<&str> {
195        std::str::from_utf8(&self.data)
196            .map_err(|e| super::error::SecretsError::InvalidData(format!("not valid UTF-8: {e}")))
197    }
198
199    /// Get the secret as bytes.
200    #[must_use]
201    pub fn as_bytes(&self) -> &[u8] {
202        &self.data
203    }
204
205    /// Check if the secret has expired based on TTL.
206    #[must_use]
207    pub fn is_expired(&self, ttl_secs: u64) -> bool {
208        self.fetched_at
209            .elapsed()
210            .map_or(true, |d| d.as_secs() >= ttl_secs)
211    }
212
213    /// Check if the secret is within the stale grace period.
214    #[must_use]
215    pub fn is_within_grace(&self, ttl_secs: u64, grace_secs: u64) -> bool {
216        self.fetched_at
217            .elapsed()
218            .is_ok_and(|d| d.as_secs() <= ttl_secs + grace_secs)
219    }
220}
221
222/// Metadata about a secret.
223#[derive(Debug, Clone, Default, Serialize, Deserialize)]
224pub struct SecretMetadata {
225    /// Version identifier from the provider.
226    pub version: Option<String>,
227
228    /// Provider-specific ARN or path.
229    pub source_path: Option<String>,
230
231    /// Provider name.
232    pub provider: Option<String>,
233}
234
235/// Event emitted when a secret is rotated.
236#[derive(Debug, Clone)]
237pub struct RotationEvent {
238    /// Secret name.
239    pub name: String,
240
241    /// Previous version (if known).
242    pub old_version: Option<String>,
243
244    /// New version.
245    pub new_version: String,
246
247    /// When the rotation was detected.
248    pub rotated_at: SystemTime,
249}
250
251/// Serializable cache entry for disk storage.
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub(crate) struct CacheEntry {
254    /// Base64-encoded secret data.
255    pub data: String,
256
257    /// When this secret was fetched (Unix timestamp).
258    pub fetched_at_secs: u64,
259
260    /// Metadata.
261    pub metadata: SecretMetadata,
262}
263
264impl CacheEntry {
265    /// Create a cache entry from a secret value.
266    pub fn from_value(value: &SecretValue) -> Self {
267        use base64::Engine;
268        Self {
269            data: base64::engine::general_purpose::STANDARD.encode(&value.data),
270            fetched_at_secs: value
271                .fetched_at
272                .duration_since(SystemTime::UNIX_EPOCH)
273                .map_or(0, |d| d.as_secs()),
274            metadata: value.metadata.clone(),
275        }
276    }
277
278    /// Convert to a secret value.
279    pub fn to_value(&self) -> SecretsResult<SecretValue> {
280        use base64::Engine;
281        let data = base64::engine::general_purpose::STANDARD
282            .decode(&self.data)
283            .map_err(|e| super::error::SecretsError::CacheError(format!("invalid base64: {e}")))?;
284
285        let fetched_at =
286            SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(self.fetched_at_secs);
287
288        Ok(SecretValue {
289            data,
290            fetched_at,
291            metadata: self.metadata.clone(),
292        })
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn test_secret_value_new() {
302        let value = SecretValue::new(b"test-secret".to_vec());
303        assert_eq!(value.as_bytes(), b"test-secret");
304        assert_eq!(value.as_str().unwrap(), "test-secret");
305    }
306
307    #[test]
308    fn test_secret_value_expiry() {
309        let value = SecretValue::new(b"test".to_vec());
310        // Fresh secret should not be expired
311        assert!(!value.is_expired(3600));
312        assert!(value.is_within_grace(3600, 86400));
313    }
314
315    #[test]
316    fn test_cache_entry_roundtrip() {
317        let value = SecretValue::new(b"secret-data".to_vec());
318        let entry = CacheEntry::from_value(&value);
319        let restored = entry.to_value().unwrap();
320        assert_eq!(restored.data, value.data);
321    }
322
323    #[test]
324    fn test_secret_source_file_serialization() {
325        let source = SecretSource::File {
326            path: "/etc/ssl/cert.pem".to_string(),
327        };
328        let json = serde_json::to_string(&source).unwrap();
329        assert!(json.contains("\"provider\":\"file\""));
330    }
331
332    #[test]
333    fn test_cache_config_default() {
334        let config = CacheConfig::default();
335        assert!(config.enabled);
336        assert_eq!(config.ttl_secs, 3600);
337        assert_eq!(config.stale_grace_secs, 86400);
338        assert!(config.encryption_key.is_none());
339    }
340}