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:   BUSL-1.1
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    /// Disk-cache encryption key. When set, on-disk cache entries are sealed
87    /// with AES-256-GCM (see `secrets::crypto`); the value is redacted in
88    /// serialisation and debug output. When `None`, the disk cache is
89    /// memory-only UNLESS `allow_plaintext_disk_cache` is explicitly enabled.
90    pub encryption_key: Option<crate::SensitiveString>,
91
92    /// Explicitly permit writing UNENCRYPTED secrets to the disk cache when
93    /// no `encryption_key` is configured. Default `false`: without a key the
94    /// disk tier is silently skipped (memory-only) rather than persisting
95    /// plaintext secrets. Enabling this is rejected by
96    /// [`validate`](CacheConfig::validate) under a production profile.
97    #[serde(default)]
98    pub allow_plaintext_disk_cache: bool,
99
100    /// Unix permission mode for the cache directory. Default `0o700`.
101    /// Set to `None` to skip chmod entirely -- required on backing
102    /// stores that reject `chmod`: S3-FUSE, root-squashed NFS, some
103    /// other network mounts. Operators are responsible for upstream
104    /// perms in that case.
105    pub dir_mode: Option<u32>,
106
107    /// Unix permission mode for cache files. Default `0o600`. `None`
108    /// skips chmod, see `dir_mode`.
109    pub file_mode: Option<u32>,
110}
111
112impl Default for CacheConfig {
113    fn default() -> Self {
114        Self {
115            enabled: true,
116            directory: None,             // Auto-detect
117            ttl_secs: 3600,              // 1 hour
118            stale_grace_secs: 86400,     // 24 hours
119            refresh_interval_secs: 1800, // 30 minutes
120            refresh_jitter_secs: 300,    // 5 minutes
121            encryption_key: None,
122            allow_plaintext_disk_cache: false,
123            dir_mode: Some(0o700),
124            file_mode: Some(0o600),
125        }
126    }
127}
128
129impl CacheConfig {
130    /// Validate the cache configuration against the deployment profile.
131    ///
132    /// Rejects writing plaintext secrets to disk in production: if the disk
133    /// cache is enabled with no `encryption_key` and
134    /// `allow_plaintext_disk_cache` is set, this returns an error under a
135    /// production profile. Call at startup (e.g. from the config registry).
136    ///
137    /// # Errors
138    ///
139    /// Returns `Err` when `is_production` and the config would persist
140    /// unencrypted secrets to disk.
141    pub fn validate(&self, is_production: bool) -> Result<(), String> {
142        if is_production
143            && self.enabled
144            && self.encryption_key.is_none()
145            && self.allow_plaintext_disk_cache
146        {
147            return Err(
148                "secrets cache: allow_plaintext_disk_cache=true is not permitted in \
149                 production -- configure an encryption_key for the disk cache, or leave \
150                 it memory-only (allow_plaintext_disk_cache=false)"
151                    .to_string(),
152            );
153        }
154        Ok(())
155    }
156}
157
158/// Configuration for a secret source.
159#[derive(Debug, Clone, Serialize, Deserialize)]
160#[serde(tag = "provider", rename_all = "snake_case")]
161pub enum SecretSource {
162    /// Load from local file.
163    File {
164        /// Path to the secret file.
165        path: String,
166    },
167
168    /// Load from OpenBao/Vault.
169    OpenBao {
170        /// Secret path in Vault (e.g., "secret/data/myapp/tls").
171        path: String,
172        /// Key within the secret (e.g., "certificate").
173        key: String,
174    },
175
176    /// Load from AWS Secrets Manager.
177    Aws {
178        /// Secret name or ARN.
179        secret_id: String,
180        /// Key within the JSON secret (optional for plaintext secrets).
181        key: Option<String>,
182    },
183}
184
185/// Value retrieved from a secrets provider.
186#[derive(Clone)]
187pub struct SecretValue {
188    /// The secret data (may be binary or text).
189    pub data: Vec<u8>,
190
191    /// When this secret was fetched.
192    pub fetched_at: SystemTime,
193
194    /// Metadata from the provider.
195    pub metadata: SecretMetadata,
196}
197
198impl std::fmt::Debug for SecretValue {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        f.debug_struct("SecretValue")
201            .field("data", &"[REDACTED]")
202            .field("fetched_at", &self.fetched_at)
203            .field("metadata", &self.metadata)
204            .finish()
205    }
206}
207
208impl SecretValue {
209    /// Create a new secret value.
210    #[must_use]
211    pub fn new(data: Vec<u8>) -> Self {
212        Self {
213            data,
214            fetched_at: SystemTime::now(),
215            metadata: SecretMetadata::default(),
216        }
217    }
218
219    /// Create a new secret value with metadata.
220    #[must_use]
221    pub fn with_metadata(data: Vec<u8>, metadata: SecretMetadata) -> Self {
222        Self {
223            data,
224            fetched_at: SystemTime::now(),
225            metadata,
226        }
227    }
228
229    /// Get the secret as a UTF-8 string.
230    ///
231    /// # Errors
232    ///
233    /// Returns an error if the data is not valid UTF-8.
234    pub fn as_str(&self) -> SecretsResult<&str> {
235        std::str::from_utf8(&self.data)
236            .map_err(|e| super::error::SecretsError::InvalidData(format!("not valid UTF-8: {e}")))
237    }
238
239    /// Get the secret as bytes.
240    #[must_use]
241    pub fn as_bytes(&self) -> &[u8] {
242        &self.data
243    }
244
245    /// Check if the secret has expired based on TTL.
246    #[must_use]
247    pub fn is_expired(&self, ttl_secs: u64) -> bool {
248        self.fetched_at
249            .elapsed()
250            .map_or(true, |d| d.as_secs() >= ttl_secs)
251    }
252
253    /// Check if the secret is within the stale grace period.
254    #[must_use]
255    pub fn is_within_grace(&self, ttl_secs: u64, grace_secs: u64) -> bool {
256        self.fetched_at
257            .elapsed()
258            .is_ok_and(|d| d.as_secs() <= ttl_secs + grace_secs)
259    }
260}
261
262/// Metadata about a secret.
263#[derive(Debug, Clone, Default, Serialize, Deserialize)]
264pub struct SecretMetadata {
265    /// Version identifier from the provider.
266    pub version: Option<String>,
267
268    /// Provider-specific ARN or path.
269    pub source_path: Option<String>,
270
271    /// Provider name.
272    pub provider: Option<String>,
273}
274
275/// Event emitted when a secret is rotated.
276#[derive(Debug, Clone)]
277pub struct RotationEvent {
278    /// Secret name.
279    pub name: String,
280
281    /// Previous version (if known).
282    pub old_version: Option<String>,
283
284    /// New version.
285    pub new_version: String,
286
287    /// When the rotation was detected.
288    pub rotated_at: SystemTime,
289}
290
291/// Serializable cache entry for disk storage.
292#[derive(Debug, Clone, Serialize, Deserialize)]
293pub(crate) struct CacheEntry {
294    /// Base64-encoded secret data.
295    pub data: String,
296
297    /// When this secret was fetched (Unix timestamp).
298    pub fetched_at_secs: u64,
299
300    /// Metadata.
301    pub metadata: SecretMetadata,
302}
303
304impl CacheEntry {
305    /// Create a cache entry from a secret value.
306    pub fn from_value(value: &SecretValue) -> Self {
307        use base64::Engine;
308        Self {
309            data: base64::engine::general_purpose::STANDARD.encode(&value.data),
310            fetched_at_secs: value
311                .fetched_at
312                .duration_since(SystemTime::UNIX_EPOCH)
313                .map_or(0, |d| d.as_secs()),
314            metadata: value.metadata.clone(),
315        }
316    }
317
318    /// Convert to a secret value.
319    pub fn to_value(&self) -> SecretsResult<SecretValue> {
320        use base64::Engine;
321        let data = base64::engine::general_purpose::STANDARD
322            .decode(&self.data)
323            .map_err(|e| super::error::SecretsError::CacheError(format!("invalid base64: {e}")))?;
324
325        let fetched_at =
326            SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(self.fetched_at_secs);
327
328        Ok(SecretValue {
329            data,
330            fetched_at,
331            metadata: self.metadata.clone(),
332        })
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    fn cache_config_validate_rejects_plaintext_disk_in_prod() {
342        // Plaintext disk opt-in is rejected in production...
343        let cfg = CacheConfig {
344            allow_plaintext_disk_cache: true,
345            encryption_key: None,
346            ..Default::default()
347        };
348        assert!(
349            cfg.validate(true).is_err(),
350            "prod must reject plaintext disk"
351        );
352        // ...but allowed outside production (dev/test convenience).
353        assert!(
354            cfg.validate(false).is_ok(),
355            "non-prod allows plaintext disk"
356        );
357
358        // Default (memory-only) is fine everywhere.
359        let safe = CacheConfig::default();
360        assert!(safe.validate(true).is_ok());
361        assert!(safe.validate(false).is_ok());
362
363        // An encryption_key makes disk persistence acceptable in prod.
364        let encrypted = CacheConfig {
365            allow_plaintext_disk_cache: true,
366            encryption_key: Some(crate::SensitiveString::from("0123456789abcdef")),
367            ..Default::default()
368        };
369        assert!(
370            encrypted.validate(true).is_ok(),
371            "an encryption_key satisfies prod validation"
372        );
373    }
374
375    #[test]
376    fn test_secret_value_new() {
377        let value = SecretValue::new(b"test-secret".to_vec());
378        assert_eq!(value.as_bytes(), b"test-secret");
379        assert_eq!(value.as_str().unwrap(), "test-secret");
380    }
381
382    #[test]
383    fn test_secret_value_expiry() {
384        let value = SecretValue::new(b"test".to_vec());
385        // Fresh secret should not be expired
386        assert!(!value.is_expired(3600));
387        assert!(value.is_within_grace(3600, 86400));
388    }
389
390    #[test]
391    fn test_cache_entry_roundtrip() {
392        let value = SecretValue::new(b"secret-data".to_vec());
393        let entry = CacheEntry::from_value(&value);
394        let restored = entry.to_value().unwrap();
395        assert_eq!(restored.data, value.data);
396    }
397
398    #[test]
399    fn test_secret_source_file_serialization() {
400        let source = SecretSource::File {
401            path: "/etc/ssl/cert.pem".to_string(),
402        };
403        let json = serde_json::to_string(&source).unwrap();
404        assert!(json.contains("\"provider\":\"file\""));
405    }
406
407    #[test]
408    fn test_cache_config_default() {
409        let config = CacheConfig::default();
410        assert!(config.enabled);
411        assert_eq!(config.ttl_secs, 3600);
412        assert_eq!(config.stale_grace_secs, 86400);
413        assert!(config.encryption_key.is_none());
414    }
415}