fakecloud_secretsmanager/
rotation.rs1use std::sync::Arc;
2
3use chrono::Utc;
4
5use fakecloud_core::delivery::DeliveryBus;
6
7use crate::state::{SecretVersion, SharedSecretsManagerState};
8
9pub async fn check_and_rotate(
24 state: &SharedSecretsManagerState,
25 delivery_bus: Option<&Arc<DeliveryBus>>,
26 snapshot_store: Option<Arc<dyn fakecloud_persistence::SnapshotStore>>,
27) -> Vec<String> {
28 let now = Utc::now();
29 let mut rotated = Vec::new();
30
31 let due_secrets: Vec<DueSecret> = {
33 let accounts = state.read();
34 accounts
35 .iter()
36 .flat_map(|(_, acct)| acct.secrets.values())
37 .filter_map(|secret| {
38 if secret.deleted {
39 return None;
40 }
41 if secret.rotation_enabled != Some(true) {
42 return None;
43 }
44 let rules = secret.rotation_rules.as_ref()?;
45 let days = rules.automatically_after_days?;
46 let last = secret.last_rotated_at?;
47 let due_at = last + chrono::Duration::days(days);
48 if now < due_at {
49 return None;
50 }
51 Some(DueSecret {
52 name: secret.name.clone(),
53 arn: secret.arn.clone(),
54 lambda_arn: secret.rotation_lambda_arn.clone(),
55 })
56 })
57 .collect()
58 };
59
60 for due in due_secrets {
62 let version_id = uuid::Uuid::new_v4().to_string();
63
64 let (invocation, version_created) = {
66 let mut accounts = state.write();
67 let account_id = due.arn.split(':').nth(4).unwrap_or("").to_string();
69 let acct = match accounts.get_mut(&account_id) {
70 Some(a) => a,
71 None => continue,
72 };
73 let secret = match acct.secrets.get_mut(&due.name) {
74 Some(s) => s,
75 None => continue,
76 };
77
78 secret.last_rotated_at = Some(now);
79 secret.last_changed_at = now;
80
81 let current_value = secret
83 .current_version_id
84 .as_ref()
85 .and_then(|vid| secret.versions.get(vid))
86 .cloned();
87
88 let mut version_created = false;
89
90 if let Some(cv) = current_value {
91 if due.lambda_arn.is_some() {
92 let version = SecretVersion {
94 version_id: version_id.clone(),
95 secret_string: cv.secret_string.clone(),
96 secret_binary: cv.secret_binary.clone(),
97 stages: vec!["AWSPENDING".to_string()],
98 created_at: now,
99 };
100 secret.versions.insert(version_id.clone(), version);
101 } else {
102 if let Some(old_vid) = secret.current_version_id.clone() {
104 if let Some(old_v) = secret.versions.get_mut(&old_vid) {
105 old_v.stages.retain(|s| s != "AWSCURRENT");
106 if !old_v.stages.contains(&"AWSPREVIOUS".to_string()) {
107 old_v.stages.push("AWSPREVIOUS".to_string());
108 }
109 }
110 }
111 let version = SecretVersion {
112 version_id: version_id.clone(),
113 secret_string: cv.secret_string.clone(),
114 secret_binary: cv.secret_binary.clone(),
115 stages: vec!["AWSCURRENT".to_string()],
116 created_at: now,
117 };
118 secret.versions.insert(version_id.clone(), version);
119 secret.current_version_id = Some(version_id.clone());
120 }
121 version_created = true;
122 }
123
124 let invocation = if version_created {
125 due.lambda_arn.as_ref().map(|arn| RotationInvocation {
126 lambda_arn: arn.clone(),
127 secret_arn: due.arn.clone(),
128 client_request_token: version_id.clone(),
129 })
130 } else {
131 None
132 };
133
134 (invocation, version_created)
135 };
136
137 if let Some(inv) = invocation {
139 if let Some(bus) = delivery_bus {
140 for step in &["createSecret", "setSecret", "testSecret", "finishSecret"] {
141 let payload = serde_json::json!({
142 "SecretId": inv.secret_arn,
143 "ClientRequestToken": inv.client_request_token,
144 "Step": step,
145 });
146 let payload_str = payload.to_string();
147 match bus.invoke_lambda(&inv.lambda_arn, &payload_str).await {
148 Some(Ok(_)) => {}
149 Some(Err(e)) => {
150 tracing::warn!(
151 step = step,
152 error = %e,
153 "scheduled rotation Lambda invocation failed"
154 );
155 }
156 None => {
157 tracing::warn!(
158 lambda_arn = %inv.lambda_arn,
159 step = step,
160 "rotation Lambda delivery not configured; skipped"
161 );
162 break;
163 }
164 }
165 }
166 }
167 }
168
169 if version_created {
170 rotated.push(due.name);
171 }
172 }
173
174 if !rotated.is_empty() {
178 let lock = tokio::sync::Mutex::new(());
179 crate::service::save_secretsmanager_snapshot(state, snapshot_store, &lock).await;
180 }
181
182 rotated
183}
184
185struct DueSecret {
186 name: String,
187 arn: String,
188 lambda_arn: Option<String>,
189}
190
191struct RotationInvocation {
192 lambda_arn: String,
193 secret_arn: String,
194 client_request_token: String,
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use crate::state::*;
201 use chrono::Duration;
202 use parking_lot::RwLock;
203 use std::collections::BTreeMap;
204 use std::sync::Arc;
205
206 fn make_state() -> SharedSecretsManagerState {
207 Arc::new(RwLock::new(
208 fakecloud_core::multi_account::MultiAccountState::new(
209 "123456789012",
210 "us-east-1",
211 "http://localhost:4566",
212 ),
213 ))
214 }
215
216 fn make_secret(
217 name: &str,
218 rotation_enabled: bool,
219 days: Option<i64>,
220 last_rotated_ago_days: Option<i64>,
221 ) -> Secret {
222 let now = Utc::now();
223 let last_rotated = last_rotated_ago_days.map(|d| now - Duration::days(d));
224 let version_id = "v1".to_string();
225
226 let mut versions = BTreeMap::new();
227 versions.insert(
228 version_id.clone(),
229 SecretVersion {
230 version_id: version_id.clone(),
231 secret_string: Some("secret-value".to_string()),
232 secret_binary: None,
233 stages: vec!["AWSCURRENT".to_string()],
234 created_at: now,
235 },
236 );
237
238 Secret {
239 name: name.to_string(),
240 arn: format!(
241 "arn:aws:secretsmanager:us-east-1:123456789012:secret:{}",
242 name
243 ),
244 description: None,
245 kms_key_id: None,
246 versions,
247 current_version_id: Some(version_id),
248 tags: vec![],
249 tags_ever_set: false,
250 deleted: false,
251 deletion_date: None,
252 created_at: now,
253 last_changed_at: now,
254 last_accessed_at: None,
255 rotation_enabled: Some(rotation_enabled),
256 rotation_lambda_arn: None, rotation_rules: days.map(|d| RotationRules {
258 automatically_after_days: Some(d),
259 duration: None,
260 schedule_expression: None,
261 }),
262 last_rotated_at: last_rotated,
263 resource_policy: None,
264 replica_regions: Vec::new(),
265 }
266 }
267
268 #[tokio::test]
269 async fn rotation_due_triggers_rotation() {
270 let state = make_state();
271 let secret = make_secret("due-secret", true, Some(1), Some(2));
273 state
274 .write()
275 .default_mut()
276 .secrets
277 .insert("due-secret".to_string(), secret);
278
279 let rotated = check_and_rotate(&state, None, None).await;
280 assert_eq!(rotated, vec!["due-secret"]);
281
282 let _accts = state.read();
284 let s = _accts.default_ref();
285 let secret = &s.secrets["due-secret"];
286 assert!(secret.versions.len() > 1, "new version should be created");
287 }
288
289 #[tokio::test]
290 async fn rotation_not_due_skipped() {
291 let state = make_state();
292 let secret = make_secret("not-due", true, Some(30), Some(1));
294 state
295 .write()
296 .default_mut()
297 .secrets
298 .insert("not-due".to_string(), secret);
299
300 let rotated = check_and_rotate(&state, None, None).await;
301 assert!(rotated.is_empty());
302 }
303
304 #[tokio::test]
305 async fn rotation_disabled_skipped() {
306 let state = make_state();
307 let secret = make_secret("disabled", false, Some(1), Some(2));
308 state
309 .write()
310 .default_mut()
311 .secrets
312 .insert("disabled".to_string(), secret);
313
314 let rotated = check_and_rotate(&state, None, None).await;
315 assert!(rotated.is_empty());
316 }
317
318 #[tokio::test]
319 async fn rotation_without_rules_skipped() {
320 let state = make_state();
321 let secret = make_secret("no-rules", true, None, Some(2));
322 state
323 .write()
324 .default_mut()
325 .secrets
326 .insert("no-rules".to_string(), secret);
327
328 let rotated = check_and_rotate(&state, None, None).await;
329 assert!(rotated.is_empty());
330 }
331
332 #[tokio::test]
333 async fn rotation_without_last_rotated_skipped() {
334 let state = make_state();
335 let secret = make_secret("no-last", true, Some(1), None);
336 state
337 .write()
338 .default_mut()
339 .secrets
340 .insert("no-last".to_string(), secret);
341
342 let rotated = check_and_rotate(&state, None, None).await;
343 assert!(rotated.is_empty());
344 }
345
346 #[tokio::test]
347 async fn deleted_secret_skipped() {
348 let state = make_state();
349 let mut secret = make_secret("deleted", true, Some(1), Some(2));
350 secret.deleted = true;
351 state
352 .write()
353 .default_mut()
354 .secrets
355 .insert("deleted".to_string(), secret);
356
357 let rotated = check_and_rotate(&state, None, None).await;
358 assert!(rotated.is_empty());
359 }
360
361 #[derive(Default)]
364 struct RecordingStore {
365 bytes: std::sync::Mutex<Option<Vec<u8>>>,
366 }
367 impl fakecloud_persistence::SnapshotStore for RecordingStore {
368 fn load(&self) -> std::io::Result<Option<Vec<u8>>> {
369 Ok(self.bytes.lock().unwrap().clone())
370 }
371 fn save(&self, bytes: &[u8]) -> std::io::Result<()> {
372 *self.bytes.lock().unwrap() = Some(bytes.to_vec());
373 Ok(())
374 }
375 }
376
377 #[tokio::test]
378 async fn rotation_persists_through_snapshot_store() {
379 let state = make_state();
383 let secret = make_secret("due-secret", true, Some(1), Some(2));
384 let original_vid = secret.current_version_id.clone();
385 state
386 .write()
387 .default_mut()
388 .secrets
389 .insert("due-secret".to_string(), secret);
390
391 let store = Arc::new(RecordingStore::default());
392 let rotated = check_and_rotate(
393 &state,
394 None,
395 Some(store.clone() as Arc<dyn fakecloud_persistence::SnapshotStore>),
396 )
397 .await;
398 assert_eq!(rotated, vec!["due-secret"]);
399
400 let bytes = fakecloud_persistence::SnapshotStore::load(store.as_ref())
403 .unwrap()
404 .expect("rotation must persist a snapshot");
405 let snap: crate::SecretsManagerSnapshot = serde_json::from_slice(&bytes).unwrap();
406 let accounts = snap.accounts.expect("multi-account snapshot");
407 let persisted = &accounts.default_ref().secrets["due-secret"];
408 assert_ne!(
409 persisted.current_version_id, original_vid,
410 "persisted snapshot must hold the rotated version"
411 );
412 }
413}