llm_shield_cloud_aws/
secrets.rs

1//! AWS Secrets Manager integration.
2//!
3//! Provides implementation of `CloudSecretManager` trait for AWS Secrets Manager.
4
5use aws_sdk_secretsmanager::Client;
6use llm_shield_cloud::{
7    async_trait, CloudError, CloudSecretManager, Result, SecretCache, SecretMetadata, SecretValue,
8};
9use std::collections::HashMap;
10use std::sync::Arc;
11
12/// AWS Secrets Manager implementation of `CloudSecretManager`.
13///
14/// This implementation provides:
15/// - Automatic credential discovery (env → file → IAM role)
16/// - Built-in secret caching with TTL
17/// - Support for both string and binary secrets
18/// - Automatic retry with exponential backoff
19///
20/// # Example
21///
22/// ```no_run
23/// use llm_shield_cloud_aws::AwsSecretsManager;
24/// use llm_shield_cloud::CloudSecretManager;
25///
26/// #[tokio::main]
27/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
28///     let manager = AwsSecretsManager::new().await?;
29///     let secret = manager.get_secret("my-secret").await?;
30///     println!("Secret: {}", secret.as_string());
31///     Ok(())
32/// }
33/// ```
34pub struct AwsSecretsManager {
35    client: Client,
36    cache: SecretCache,
37    region: String,
38}
39
40impl AwsSecretsManager {
41    /// Creates a new AWS Secrets Manager client with default configuration.
42    ///
43    /// Uses the AWS credential provider chain:
44    /// 1. Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
45    /// 2. AWS credentials file (~/.aws/credentials)
46    /// 3. IAM role for ECS task
47    /// 4. IAM role for EC2 instance
48    /// 5. IAM role for EKS pod (IRSA)
49    ///
50    /// # Errors
51    ///
52    /// Returns error if AWS configuration cannot be loaded.
53    pub async fn new() -> Result<Self> {
54        let config = aws_config::load_from_env().await;
55        let region = config
56            .region()
57            .map(|r| r.to_string())
58            .unwrap_or_else(|| "us-east-1".to_string());
59
60        let client = Client::new(&config);
61        let cache = SecretCache::new(300); // 5 minute default TTL
62
63        tracing::info!("Initialized AWS Secrets Manager client in region: {}", region);
64
65        Ok(Self {
66            client,
67            cache,
68            region,
69        })
70    }
71
72    /// Creates a new AWS Secrets Manager client with specific region.
73    ///
74    /// # Arguments
75    ///
76    /// * `region` - AWS region (e.g., "us-east-1", "eu-west-1")
77    ///
78    /// # Errors
79    ///
80    /// Returns error if AWS configuration cannot be loaded.
81    pub async fn new_with_region(region: impl Into<String>) -> Result<Self> {
82        let region_str = region.into();
83        let config = aws_config::from_env()
84            .region(aws_config::Region::new(region_str.clone()))
85            .load()
86            .await;
87
88        let client = Client::new(&config);
89        let cache = SecretCache::new(300);
90
91        tracing::info!("Initialized AWS Secrets Manager client in region: {}", region_str);
92
93        Ok(Self {
94            client,
95            cache,
96            region: region_str,
97        })
98    }
99
100    /// Creates a new AWS Secrets Manager client with custom cache TTL.
101    ///
102    /// # Arguments
103    ///
104    /// * `region` - AWS region
105    /// * `cache_ttl_seconds` - Cache time-to-live in seconds
106    ///
107    /// # Errors
108    ///
109    /// Returns error if AWS configuration cannot be loaded.
110    pub async fn new_with_cache_ttl(
111        region: impl Into<String>,
112        cache_ttl_seconds: u64,
113    ) -> Result<Self> {
114        let region_str = region.into();
115        let config = aws_config::from_env()
116            .region(aws_config::Region::new(region_str.clone()))
117            .load()
118            .await;
119
120        let client = Client::new(&config);
121        let cache = SecretCache::new(cache_ttl_seconds);
122
123        tracing::info!(
124            "Initialized AWS Secrets Manager client in region: {} with {}s cache TTL",
125            region_str,
126            cache_ttl_seconds
127        );
128
129        Ok(Self {
130            client,
131            cache,
132            region: region_str,
133        })
134    }
135
136    /// Gets the AWS region this client is configured for.
137    pub fn region(&self) -> &str {
138        &self.region
139    }
140
141    /// Clears the secret cache.
142    pub async fn clear_cache(&self) {
143        self.cache.clear().await;
144        tracing::debug!("Cleared AWS Secrets Manager cache");
145    }
146
147    /// Gets the number of cached secrets.
148    pub async fn cache_size(&self) -> usize {
149        self.cache.len().await
150    }
151}
152
153#[async_trait]
154impl CloudSecretManager for AwsSecretsManager {
155    async fn get_secret(&self, name: &str) -> Result<SecretValue> {
156        // Check cache first
157        if let Some(cached) = self.cache.get(name).await {
158            tracing::debug!("Cache hit for secret: {}", name);
159            return Ok(cached);
160        }
161
162        tracing::debug!("Fetching secret from AWS Secrets Manager: {}", name);
163
164        // Fetch from AWS Secrets Manager
165        let response = self
166            .client
167            .get_secret_value()
168            .secret_id(name)
169            .send()
170            .await
171            .map_err(|e| CloudError::secret_fetch(name, e.to_string()))?;
172
173        // Handle both string and binary secrets
174        let value = if let Some(secret_string) = response.secret_string() {
175            SecretValue::from_string(secret_string.to_string())
176        } else if let Some(secret_binary) = response.secret_binary() {
177            SecretValue::from_bytes(secret_binary.clone().into_inner())
178        } else {
179            return Err(CloudError::SecretFormat {
180                name: name.to_string(),
181                reason: "Secret has no string or binary value".to_string(),
182            });
183        };
184
185        // Cache the secret
186        self.cache.set(name.to_string(), value.clone()).await;
187
188        tracing::info!("Successfully fetched secret: {}", name);
189
190        Ok(value)
191    }
192
193    async fn list_secrets(&self) -> Result<Vec<String>> {
194        tracing::debug!("Listing secrets from AWS Secrets Manager");
195
196        let mut secret_names = Vec::new();
197        let mut next_token: Option<String> = None;
198
199        loop {
200            let mut request = self.client.list_secrets();
201
202            if let Some(token) = next_token {
203                request = request.next_token(token);
204            }
205
206            let response = request
207                .send()
208                .await
209                .map_err(|e| CloudError::SecretList(e.to_string()))?;
210
211            for secret in response.secret_list() {
212                if let Some(name) = secret.name() {
213                    secret_names.push(name.to_string());
214                }
215            }
216
217            next_token = response.next_token().map(String::from);
218
219            if next_token.is_none() {
220                break;
221            }
222        }
223
224        tracing::info!("Listed {} secrets", secret_names.len());
225
226        Ok(secret_names)
227    }
228
229    async fn create_secret(&self, name: &str, value: &SecretValue) -> Result<()> {
230        tracing::debug!("Creating secret in AWS Secrets Manager: {}", name);
231
232        self.client
233            .create_secret()
234            .name(name)
235            .secret_string(value.as_string())
236            .send()
237            .await
238            .map_err(|e| CloudError::secret_create(name, e.to_string()))?;
239
240        tracing::info!("Successfully created secret: {}", name);
241
242        Ok(())
243    }
244
245    async fn update_secret(&self, name: &str, value: &SecretValue) -> Result<()> {
246        tracing::debug!("Updating secret in AWS Secrets Manager: {}", name);
247
248        self.client
249            .update_secret()
250            .secret_id(name)
251            .secret_string(value.as_string())
252            .send()
253            .await
254            .map_err(|e| CloudError::secret_update(name, e.to_string()))?;
255
256        // Invalidate cache
257        self.cache.invalidate(name).await;
258
259        tracing::info!("Successfully updated secret: {}", name);
260
261        Ok(())
262    }
263
264    async fn delete_secret(&self, name: &str) -> Result<()> {
265        tracing::debug!("Deleting secret from AWS Secrets Manager: {}", name);
266
267        self.client
268            .delete_secret()
269            .secret_id(name)
270            .force_delete_without_recovery(false) // 30-day recovery window
271            .send()
272            .await
273            .map_err(|e| CloudError::secret_delete(name, e.to_string()))?;
274
275        // Invalidate cache
276        self.cache.invalidate(name).await;
277
278        tracing::info!("Successfully deleted secret (30-day recovery): {}", name);
279
280        Ok(())
281    }
282
283    async fn get_secret_metadata(&self, name: &str) -> Result<SecretMetadata> {
284        tracing::debug!("Fetching secret metadata from AWS Secrets Manager: {}", name);
285
286        let response = self
287            .client
288            .describe_secret()
289            .secret_id(name)
290            .send()
291            .await
292            .map_err(|e| CloudError::secret_fetch(name, e.to_string()))?;
293
294        let created_at = response
295            .created_date()
296            .and_then(|dt| {
297                chrono::DateTime::from_timestamp(dt.secs(), dt.subsec_nanos())
298            })
299            .unwrap_or_else(chrono::Utc::now);
300
301        let updated_at = response
302            .last_changed_date()
303            .and_then(|dt| {
304                chrono::DateTime::from_timestamp(dt.secs(), dt.subsec_nanos())
305            })
306            .unwrap_or(created_at);
307
308        let mut tags = HashMap::new();
309        for tag in response.tags() {
310            if let (Some(key), Some(value)) = (tag.key(), tag.value()) {
311                tags.insert(key.to_string(), value.to_string());
312            }
313        }
314
315        let version = response.version_ids_to_stages().and_then(|versions| {
316            versions
317                .iter()
318                .find(|(_, stages)| stages.contains(&"AWSCURRENT".to_string()))
319                .map(|(version_id, _)| version_id.clone())
320        });
321
322        Ok(SecretMetadata {
323            name: name.to_string(),
324            created_at,
325            updated_at,
326            tags,
327            version,
328        })
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn test_aws_secrets_manager_region() {
338        // This test just checks the struct can be created with a region
339        // Actual AWS operations require real credentials and are in integration tests
340        let region = "us-west-2".to_string();
341        // We can't create the client without AWS credentials, so just test the logic
342        assert_eq!(region, "us-west-2");
343    }
344
345    #[tokio::test]
346    async fn test_cache_operations() {
347        // Test the cache independently without AWS calls
348        let cache = SecretCache::new(300);
349
350        let test_secret = SecretValue::from_string("test-value".to_string());
351        cache.set("test-key".to_string(), test_secret.clone()).await;
352
353        let retrieved = cache.get("test-key").await;
354        assert!(retrieved.is_some());
355        assert_eq!(retrieved.unwrap().as_string(), "test-value");
356
357        cache.clear().await;
358        assert_eq!(cache.len().await, 0);
359    }
360}