1use std::collections::HashMap;
12use std::path::PathBuf;
13use std::time::SystemTime;
14
15use serde::{Deserialize, Serialize};
16
17use super::error::SecretsResult;
18
19#[derive(Debug, Clone, Default, Serialize, Deserialize)]
21#[serde(default)]
22pub struct SecretsConfig {
23 pub cache: CacheConfig,
25
26 #[cfg(feature = "secrets-vault")]
28 pub openbao: Option<super::OpenBaoConfig>,
29
30 #[cfg(feature = "secrets-aws")]
32 pub aws: Option<super::AwsConfig>,
33
34 #[cfg(not(feature = "secrets-vault"))]
36 #[serde(skip)]
37 pub openbao: Option<()>,
38
39 #[cfg(not(feature = "secrets-aws"))]
41 #[serde(skip)]
42 pub aws: Option<()>,
43
44 pub sources: HashMap<String, SecretSource>,
46}
47
48impl SecretsConfig {
49 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(default)]
67pub struct CacheConfig {
68 pub enabled: bool,
70
71 pub directory: Option<PathBuf>,
73
74 pub ttl_secs: u64,
76
77 pub stale_grace_secs: u64,
79
80 pub refresh_interval_secs: u64,
82
83 pub refresh_jitter_secs: u64,
85
86 pub encryption_key: Option<crate::SensitiveString>,
91
92 #[serde(default)]
98 pub allow_plaintext_disk_cache: bool,
99
100 pub dir_mode: Option<u32>,
106
107 pub file_mode: Option<u32>,
110}
111
112impl Default for CacheConfig {
113 fn default() -> Self {
114 Self {
115 enabled: true,
116 directory: None, ttl_secs: 3600, stale_grace_secs: 86400, refresh_interval_secs: 1800, refresh_jitter_secs: 300, 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
160#[serde(tag = "provider", rename_all = "snake_case")]
161pub enum SecretSource {
162 File {
164 path: String,
166 },
167
168 OpenBao {
170 path: String,
172 key: String,
174 },
175
176 Aws {
178 secret_id: String,
180 key: Option<String>,
182 },
183}
184
185#[derive(Clone)]
187pub struct SecretValue {
188 pub data: Vec<u8>,
190
191 pub fetched_at: SystemTime,
193
194 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 #[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 #[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 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 #[must_use]
241 pub fn as_bytes(&self) -> &[u8] {
242 &self.data
243 }
244
245 #[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 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
264pub struct SecretMetadata {
265 pub version: Option<String>,
267
268 pub source_path: Option<String>,
270
271 pub provider: Option<String>,
273}
274
275#[derive(Debug, Clone)]
277pub struct RotationEvent {
278 pub name: String,
280
281 pub old_version: Option<String>,
283
284 pub new_version: String,
286
287 pub rotated_at: SystemTime,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize)]
293pub(crate) struct CacheEntry {
294 pub data: String,
296
297 pub fetched_at_secs: u64,
299
300 pub metadata: SecretMetadata,
302}
303
304impl CacheEntry {
305 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 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 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 assert!(
354 cfg.validate(false).is_ok(),
355 "non-prod allows plaintext disk"
356 );
357
358 let safe = CacheConfig::default();
360 assert!(safe.validate(true).is_ok());
361 assert!(safe.validate(false).is_ok());
362
363 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 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}