Skip to main content

fakecloud_secretsmanager/
rotation.rs

1use std::sync::Arc;
2
3use chrono::Utc;
4
5use fakecloud_core::delivery::DeliveryBus;
6
7use crate::state::{SecretVersion, SharedSecretsManagerState};
8
9/// Check all secrets for due rotations and trigger them.
10///
11/// For each secret with `rotation_enabled == true`, checks whether
12/// `last_rotated_at + rotation_days <= now`. If so, performs the same
13/// rotation logic as `RotateSecret`: creates an AWSPENDING version and
14/// invokes the rotation Lambda through all four steps.
15///
16/// Returns the list of secret names that were rotated.
17///
18/// `snapshot_store`, when present, is written through after any rotation so the
19/// new AWSCURRENT version survives a restart. A scheduled rotation mutates
20/// secret state directly here, outside the normal action-dispatch path that is
21/// otherwise the only thing that snapshots -- without this the secret reverts to
22/// its pre-rotation value after a restart (bug-audit 2026-06-20, 0.A3).
23pub 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    // Collect secrets that need rotation while holding the lock briefly.
32    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    // Now perform rotation for each due secret.
61    for due in due_secrets {
62        let version_id = uuid::Uuid::new_v4().to_string();
63
64        // Mutate state: create pending version, update timestamps
65        let (invocation, version_created) = {
66            let mut accounts = state.write();
67            // Find the account that owns this secret by ARN prefix
68            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            // Get current value to clone into pending version
82            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                    // With Lambda: create AWSPENDING version
93                    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                    // Without Lambda: simple rotation
103                    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        // Invoke Lambda outside the lock
138        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    // Write the rotated state through to disk. The snapshot file is written
175    // atomically (temp + rename), so a fresh local lock is sufficient to guard
176    // the in-memory clone for this one persist.
177    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, // no Lambda for unit tests
257            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        // Rotation enabled, 1 day interval, last rotated 2 days ago → due
272        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        // Verify a new version was created (simple rotation without Lambda)
283        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        // Rotation enabled, 30 day interval, last rotated 1 day ago → not due
293        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    /// A SnapshotStore that records the last bytes saved, so a test can assert
362    /// the rotation wrote through (the real MemorySnapshotStore is a no-op).
363    #[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        // A scheduled (no-Lambda) rotation mutates secret state directly. Without
380        // write-through the new AWSCURRENT version is lost on restart and the
381        // secret reverts to its old value (bug-audit 2026-06-20, 0.A3).
382        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        // The rotated state was written through, so a reload sees the new
401        // AWSCURRENT version, not the pre-rotation one.
402        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}