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(
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 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 for due in due_secrets {
55 let version_id = uuid::Uuid::new_v4().to_string();
56
57 let (invocation, version_created) = {
59 let mut accounts = state.write();
60 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 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 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 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 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, 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 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 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 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}