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.
17pub async fn check_and_rotate(
18    state: &SharedSecretsManagerState,
19    delivery_bus: Option<&Arc<DeliveryBus>>,
20) -> Vec<String> {
21    let now = Utc::now();
22    let mut rotated = Vec::new();
23
24    // Collect secrets that need rotation while holding the lock briefly.
25    let due_secrets: Vec<DueSecret> = {
26        let accounts = state.read();
27        accounts
28            .iter()
29            .flat_map(|(_, acct)| acct.secrets.values())
30            .filter_map(|secret| {
31                if secret.deleted {
32                    return None;
33                }
34                if secret.rotation_enabled != Some(true) {
35                    return None;
36                }
37                let rules = secret.rotation_rules.as_ref()?;
38                let days = rules.automatically_after_days?;
39                let last = secret.last_rotated_at?;
40                let due_at = last + chrono::Duration::days(days);
41                if now < due_at {
42                    return None;
43                }
44                Some(DueSecret {
45                    name: secret.name.clone(),
46                    arn: secret.arn.clone(),
47                    lambda_arn: secret.rotation_lambda_arn.clone(),
48                })
49            })
50            .collect()
51    };
52
53    // Now perform rotation for each due secret.
54    for due in due_secrets {
55        let version_id = uuid::Uuid::new_v4().to_string();
56
57        // Mutate state: create pending version, update timestamps
58        let (invocation, version_created) = {
59            let mut accounts = state.write();
60            // Find the account that owns this secret by ARN prefix
61            let account_id = due.arn.split(':').nth(4).unwrap_or("").to_string();
62            let acct = match accounts.get_mut(&account_id) {
63                Some(a) => a,
64                None => continue,
65            };
66            let secret = match acct.secrets.get_mut(&due.name) {
67                Some(s) => s,
68                None => continue,
69            };
70
71            secret.last_rotated_at = Some(now);
72            secret.last_changed_at = now;
73
74            // Get current value to clone into pending version
75            let current_value = secret
76                .current_version_id
77                .as_ref()
78                .and_then(|vid| secret.versions.get(vid))
79                .cloned();
80
81            let mut version_created = false;
82
83            if let Some(cv) = current_value {
84                if due.lambda_arn.is_some() {
85                    // With Lambda: create AWSPENDING version
86                    let version = SecretVersion {
87                        version_id: version_id.clone(),
88                        secret_string: cv.secret_string.clone(),
89                        secret_binary: cv.secret_binary.clone(),
90                        stages: vec!["AWSPENDING".to_string()],
91                        created_at: now,
92                    };
93                    secret.versions.insert(version_id.clone(), version);
94                } else {
95                    // Without Lambda: simple rotation
96                    if let Some(old_vid) = secret.current_version_id.clone() {
97                        if let Some(old_v) = secret.versions.get_mut(&old_vid) {
98                            old_v.stages.retain(|s| s != "AWSCURRENT");
99                            if !old_v.stages.contains(&"AWSPREVIOUS".to_string()) {
100                                old_v.stages.push("AWSPREVIOUS".to_string());
101                            }
102                        }
103                    }
104                    let version = SecretVersion {
105                        version_id: version_id.clone(),
106                        secret_string: cv.secret_string.clone(),
107                        secret_binary: cv.secret_binary.clone(),
108                        stages: vec!["AWSCURRENT".to_string()],
109                        created_at: now,
110                    };
111                    secret.versions.insert(version_id.clone(), version);
112                    secret.current_version_id = Some(version_id.clone());
113                }
114                version_created = true;
115            }
116
117            let invocation = if version_created {
118                due.lambda_arn.as_ref().map(|arn| RotationInvocation {
119                    lambda_arn: arn.clone(),
120                    secret_arn: due.arn.clone(),
121                    client_request_token: version_id.clone(),
122                })
123            } else {
124                None
125            };
126
127            (invocation, version_created)
128        };
129
130        // Invoke Lambda outside the lock
131        if let Some(inv) = invocation {
132            if let Some(bus) = delivery_bus {
133                for step in &["createSecret", "setSecret", "testSecret", "finishSecret"] {
134                    let payload = serde_json::json!({
135                        "SecretId": inv.secret_arn,
136                        "ClientRequestToken": inv.client_request_token,
137                        "Step": step,
138                    });
139                    let payload_str = payload.to_string();
140                    match bus.invoke_lambda(&inv.lambda_arn, &payload_str).await {
141                        Some(Ok(_)) => {}
142                        Some(Err(e)) => {
143                            tracing::warn!(
144                                step = step,
145                                error = %e,
146                                "scheduled rotation Lambda invocation failed"
147                            );
148                        }
149                        None => {
150                            tracing::warn!(
151                                lambda_arn = %inv.lambda_arn,
152                                step = step,
153                                "rotation Lambda delivery not configured; skipped"
154                            );
155                            break;
156                        }
157                    }
158                }
159            }
160        }
161
162        if version_created {
163            rotated.push(due.name);
164        }
165    }
166
167    rotated
168}
169
170struct DueSecret {
171    name: String,
172    arn: String,
173    lambda_arn: Option<String>,
174}
175
176struct RotationInvocation {
177    lambda_arn: String,
178    secret_arn: String,
179    client_request_token: String,
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::state::*;
186    use chrono::Duration;
187    use parking_lot::RwLock;
188    use std::collections::BTreeMap;
189    use std::sync::Arc;
190
191    fn make_state() -> SharedSecretsManagerState {
192        Arc::new(RwLock::new(
193            fakecloud_core::multi_account::MultiAccountState::new(
194                "123456789012",
195                "us-east-1",
196                "http://localhost:4566",
197            ),
198        ))
199    }
200
201    fn make_secret(
202        name: &str,
203        rotation_enabled: bool,
204        days: Option<i64>,
205        last_rotated_ago_days: Option<i64>,
206    ) -> Secret {
207        let now = Utc::now();
208        let last_rotated = last_rotated_ago_days.map(|d| now - Duration::days(d));
209        let version_id = "v1".to_string();
210
211        let mut versions = BTreeMap::new();
212        versions.insert(
213            version_id.clone(),
214            SecretVersion {
215                version_id: version_id.clone(),
216                secret_string: Some("secret-value".to_string()),
217                secret_binary: None,
218                stages: vec!["AWSCURRENT".to_string()],
219                created_at: now,
220            },
221        );
222
223        Secret {
224            name: name.to_string(),
225            arn: format!(
226                "arn:aws:secretsmanager:us-east-1:123456789012:secret:{}",
227                name
228            ),
229            description: None,
230            kms_key_id: None,
231            versions,
232            current_version_id: Some(version_id),
233            tags: vec![],
234            tags_ever_set: false,
235            deleted: false,
236            deletion_date: None,
237            created_at: now,
238            last_changed_at: now,
239            last_accessed_at: None,
240            rotation_enabled: Some(rotation_enabled),
241            rotation_lambda_arn: None, // no Lambda for unit tests
242            rotation_rules: days.map(|d| RotationRules {
243                automatically_after_days: Some(d),
244            }),
245            last_rotated_at: last_rotated,
246            resource_policy: None,
247        }
248    }
249
250    #[tokio::test]
251    async fn rotation_due_triggers_rotation() {
252        let state = make_state();
253        // Rotation enabled, 1 day interval, last rotated 2 days ago → due
254        let secret = make_secret("due-secret", true, Some(1), Some(2));
255        state
256            .write()
257            .default_mut()
258            .secrets
259            .insert("due-secret".to_string(), secret);
260
261        let rotated = check_and_rotate(&state, None).await;
262        assert_eq!(rotated, vec!["due-secret"]);
263
264        // Verify a new version was created (simple rotation without Lambda)
265        let _accts = state.read();
266        let s = _accts.default_ref();
267        let secret = &s.secrets["due-secret"];
268        assert!(secret.versions.len() > 1, "new version should be created");
269    }
270
271    #[tokio::test]
272    async fn rotation_not_due_skipped() {
273        let state = make_state();
274        // Rotation enabled, 30 day interval, last rotated 1 day ago → not due
275        let secret = make_secret("not-due", true, Some(30), Some(1));
276        state
277            .write()
278            .default_mut()
279            .secrets
280            .insert("not-due".to_string(), secret);
281
282        let rotated = check_and_rotate(&state, None).await;
283        assert!(rotated.is_empty());
284    }
285
286    #[tokio::test]
287    async fn rotation_disabled_skipped() {
288        let state = make_state();
289        let secret = make_secret("disabled", false, Some(1), Some(2));
290        state
291            .write()
292            .default_mut()
293            .secrets
294            .insert("disabled".to_string(), secret);
295
296        let rotated = check_and_rotate(&state, None).await;
297        assert!(rotated.is_empty());
298    }
299
300    #[tokio::test]
301    async fn rotation_without_rules_skipped() {
302        let state = make_state();
303        let secret = make_secret("no-rules", true, None, Some(2));
304        state
305            .write()
306            .default_mut()
307            .secrets
308            .insert("no-rules".to_string(), secret);
309
310        let rotated = check_and_rotate(&state, None).await;
311        assert!(rotated.is_empty());
312    }
313
314    #[tokio::test]
315    async fn rotation_without_last_rotated_skipped() {
316        let state = make_state();
317        let secret = make_secret("no-last", true, Some(1), None);
318        state
319            .write()
320            .default_mut()
321            .secrets
322            .insert("no-last".to_string(), secret);
323
324        let rotated = check_and_rotate(&state, None).await;
325        assert!(rotated.is_empty());
326    }
327
328    #[tokio::test]
329    async fn deleted_secret_skipped() {
330        let state = make_state();
331        let mut secret = make_secret("deleted", true, Some(1), Some(2));
332        secret.deleted = true;
333        state
334            .write()
335            .default_mut()
336            .secrets
337            .insert("deleted".to_string(), secret);
338
339        let rotated = check_and_rotate(&state, None).await;
340        assert!(rotated.is_empty());
341    }
342}