cuenv_secrets/resolved.rs
1//! Resolved secrets with fingerprinting support
2
3use crate::{
4 BatchSecrets, SaltConfig, SecretError, SecretResolver, SecretSpec, compute_secret_fingerprint,
5};
6use std::collections::HashMap;
7
8/// Resolved secrets ready for injection
9#[derive(Debug, Clone, Default)]
10pub struct ResolvedSecrets {
11 /// Secret name -> resolved value
12 pub values: HashMap<String, String>,
13 /// Secret name -> HMAC fingerprint (for cache keys)
14 pub fingerprints: HashMap<String, String>,
15}
16
17impl ResolvedSecrets {
18 /// Create empty resolved secrets
19 #[must_use]
20 pub fn new() -> Self {
21 Self::default()
22 }
23
24 /// Resolve secrets using a resolver with salt configuration
25 ///
26 /// # Arguments
27 /// * `resolver` - The secret resolver to use
28 /// * `secrets` - Map of secret names to their configuration
29 /// * `salt_config` - Salt configuration for fingerprinting
30 ///
31 /// # Errors
32 /// Returns error if a secret cannot be resolved or if salt is missing
33 /// when secrets have `cache_key: true`
34 pub async fn resolve<R: SecretResolver>(
35 resolver: &R,
36 secrets: &HashMap<String, SecretSpec>,
37 salt_config: &SaltConfig,
38 ) -> Result<Self, SecretError> {
39 let mut values = HashMap::new();
40 let mut fingerprints = HashMap::new();
41
42 // Check if any secret requires cache key and salt is missing
43 let needs_salt = secrets.values().any(|c| c.cache_key);
44 if needs_salt && !salt_config.has_salt() {
45 return Err(SecretError::MissingSalt);
46 }
47
48 for (name, spec) in secrets {
49 let value = resolver.resolve(name, spec).await?;
50
51 // Compute fingerprint if secret affects cache
52 if spec.cache_key {
53 // Warn if secret is too short (but don't fail)
54 if value.len() < 4 {
55 tracing::warn!(
56 secret = %name,
57 len = value.len(),
58 "Secret is too short for safe cache key inclusion"
59 );
60 }
61
62 // Use write_salt for computing fingerprints (current salt preferred)
63 let fingerprint = compute_secret_fingerprint(
64 name,
65 &value,
66 salt_config.write_salt().unwrap_or(""),
67 );
68 fingerprints.insert(name.clone(), fingerprint);
69 }
70
71 values.insert(name.clone(), value);
72 }
73
74 Ok(Self {
75 values,
76 fingerprints,
77 })
78 }
79
80 /// Create from a `BatchSecrets` instance.
81 ///
82 /// This consumes the batch and converts it to the legacy format.
83 /// Note that this exposes the secret values from the secure storage.
84 #[must_use]
85 pub fn from_batch(batch: BatchSecrets) -> Self {
86 batch.into_resolved_secrets()
87 }
88
89 /// Resolve secrets using batch resolution with a resolver.
90 ///
91 /// This is the preferred method for resolving multiple secrets efficiently.
92 /// It uses the resolver's batch resolution method which may use native
93 /// batch APIs (e.g., AWS `BatchGetSecretValue`, 1Password `Secrets.ResolveAll`).
94 ///
95 /// # Arguments
96 /// * `resolver` - The secret resolver to use
97 /// * `secrets` - Map of secret names to their configuration
98 /// * `salt_config` - Salt configuration for fingerprinting
99 ///
100 /// # Errors
101 /// Returns error if a secret cannot be resolved or if salt is missing
102 /// when secrets have `cache_key: true`
103 pub async fn resolve_batch<R: SecretResolver>(
104 resolver: &R,
105 secrets: &HashMap<String, SecretSpec>,
106 salt_config: &SaltConfig,
107 ) -> Result<Self, SecretError> {
108 let batch = crate::batch::resolve_batch(resolver, secrets, salt_config).await?;
109 Ok(Self::from_batch(batch))
110 }
111
112 /// Check if any secrets were resolved
113 #[must_use]
114 pub fn is_empty(&self) -> bool {
115 self.values.is_empty()
116 }
117
118 /// Get a resolved secret value by name
119 #[must_use]
120 pub fn get(&self, name: &str) -> Option<&str> {
121 self.values.get(name).map(String::as_str)
122 }
123
124 /// Check if a cached fingerprint matches with salt rotation support
125 ///
126 /// During salt rotation, this checks if the cached fingerprint matches
127 /// using either the current or previous salt. This allows cache hits
128 /// during the rotation window.
129 ///
130 /// # Arguments
131 /// * `name` - Secret name
132 /// * `cached_fingerprint` - Fingerprint from cache
133 /// * `salt_config` - Salt configuration with current and optional previous salt
134 ///
135 /// # Returns
136 /// `true` if the fingerprint matches with either salt, `false` otherwise
137 #[must_use]
138 pub fn fingerprint_matches(
139 &self,
140 name: &str,
141 cached_fingerprint: &str,
142 salt_config: &SaltConfig,
143 ) -> bool {
144 let Some(value) = self.values.get(name) else {
145 return false;
146 };
147
148 // Check against current salt
149 if let Some(current) = &salt_config.current {
150 let current_fp = compute_secret_fingerprint(name, value, current);
151 if current_fp == cached_fingerprint {
152 return true;
153 }
154 }
155
156 // Check against previous salt (for rotation window)
157 if let Some(previous) = &salt_config.previous {
158 let previous_fp = compute_secret_fingerprint(name, value, previous);
159 if previous_fp == cached_fingerprint {
160 tracing::debug!(
161 secret = %name,
162 "Cache hit using previous salt - rotation in progress"
163 );
164 return true;
165 }
166 }
167
168 false
169 }
170
171 /// Compute fingerprints using both current and previous salts
172 ///
173 /// Returns a tuple of (`current_fingerprint`, `previous_fingerprint`) for cache validation.
174 /// Either may be None if the corresponding salt is not configured.
175 #[must_use]
176 pub fn compute_fingerprints_for_validation(
177 &self,
178 name: &str,
179 salt_config: &SaltConfig,
180 ) -> (Option<String>, Option<String>) {
181 let Some(value) = self.values.get(name) else {
182 return (None, None);
183 };
184
185 let current_fp = salt_config
186 .current
187 .as_ref()
188 .map(|salt| compute_secret_fingerprint(name, value, salt));
189
190 let previous_fp = salt_config
191 .previous
192 .as_ref()
193 .map(|salt| compute_secret_fingerprint(name, value, salt));
194
195 (current_fp, previous_fp)
196 }
197}