fakecloud_secretsmanager/
state.rs1use chrono::{DateTime, Utc};
2use parking_lot::RwLock;
3use std::collections::BTreeMap;
4use std::sync::Arc;
5
6#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
7pub struct Secret {
8 pub name: String,
9 pub arn: String,
10 pub description: Option<String>,
11 pub kms_key_id: Option<String>,
12 pub versions: BTreeMap<String, SecretVersion>,
13 pub current_version_id: Option<String>,
14 pub tags: Vec<(String, String)>,
15 pub tags_ever_set: bool,
16 pub deleted: bool,
17 pub deletion_date: Option<DateTime<Utc>>,
18 pub created_at: DateTime<Utc>,
19 pub last_changed_at: DateTime<Utc>,
20 pub last_accessed_at: Option<DateTime<Utc>>,
21 pub rotation_enabled: Option<bool>,
22 pub rotation_lambda_arn: Option<String>,
23 pub rotation_rules: Option<RotationRules>,
24 pub last_rotated_at: Option<DateTime<Utc>>,
25 pub resource_policy: Option<String>,
26 #[serde(default)]
29 pub replica_regions: Vec<String>,
30}
31
32#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
33pub struct RotationRules {
34 pub automatically_after_days: Option<i64>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub duration: Option<String>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub schedule_expression: Option<String>,
42}
43
44#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
45pub struct SecretVersion {
46 pub version_id: String,
47 pub secret_string: Option<String>,
48 pub secret_binary: Option<Vec<u8>>,
49 pub stages: Vec<String>,
50 pub created_at: DateTime<Utc>,
51}
52
53#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
54pub struct SecretsManagerState {
55 pub account_id: String,
56 pub region: String,
57 pub secrets: BTreeMap<String, Secret>,
58}
59
60impl SecretsManagerState {
61 pub fn new(account_id: &str, region: &str) -> Self {
62 Self {
63 account_id: account_id.to_string(),
64 region: region.to_string(),
65 secrets: BTreeMap::new(),
66 }
67 }
68
69 pub fn reset(&mut self) {
70 self.secrets.clear();
71 }
72
73 pub fn clear_name_for_create(
80 &mut self,
81 name: &str,
82 now: DateTime<Utc>,
83 ) -> Result<(), fakecloud_core::service::AwsServiceError> {
84 let Some(existing) = self.secrets.get(name) else {
85 return Ok(());
86 };
87 if !existing.deleted {
88 return Ok(());
89 }
90 if crate::service::secret_recovery_window_elapsed(existing, now) {
91 self.secrets.remove(name);
92 return Ok(());
93 }
94 Err(fakecloud_core::service::AwsServiceError::aws_error(
95 http::StatusCode::BAD_REQUEST,
96 "InvalidRequestException",
97 "You can't create this secret because a secret with this name is already scheduled for deletion.",
98 ))
99 }
100
101 pub fn secret_key(&self, secret_id: &str) -> Option<String> {
105 if self.secrets.contains_key(secret_id) {
106 return Some(secret_id.to_string());
107 }
108 if let Some((key, _)) = self.secrets.iter().find(|(_, s)| s.arn == secret_id) {
109 return Some(key.clone());
110 }
111 if fakecloud_aws::arn::arn_resource(secret_id, "secretsmanager").is_some() {
112 return self
113 .secrets
114 .iter()
115 .find(|(_, s)| is_partial_secret_arn(&s.arn, secret_id))
116 .map(|(key, _)| key.clone());
117 }
118 None
119 }
120}
121
122fn is_partial_secret_arn(stored: &str, partial: &str) -> bool {
126 stored
127 .strip_prefix(partial)
128 .and_then(|rest| rest.strip_prefix('-'))
129 .is_some_and(|suffix| suffix.chars().count() == 6)
130}
131
132pub type SharedSecretsManagerState =
133 Arc<RwLock<fakecloud_core::multi_account::MultiAccountState<SecretsManagerState>>>;
134
135impl fakecloud_core::multi_account::AccountState for SecretsManagerState {
136 fn new_for_account(account_id: &str, region: &str, _endpoint: &str) -> Self {
137 Self::new(account_id, region)
138 }
139}
140
141#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
144pub struct SecretsManagerSnapshot {
145 pub schema_version: u32,
146 #[serde(default)]
147 pub accounts: Option<fakecloud_core::multi_account::MultiAccountState<SecretsManagerState>>,
148 #[serde(default)]
149 pub state: Option<SecretsManagerState>,
150}
151
152pub const SECRETSMANAGER_SNAPSHOT_SCHEMA_VERSION: u32 = 2;
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 #[test]
159 fn new_initializes_empty() {
160 let state = SecretsManagerState::new("123456789012", "us-east-1");
161 assert_eq!(state.account_id, "123456789012");
162 assert_eq!(state.region, "us-east-1");
163 assert!(state.secrets.is_empty());
164 }
165
166 #[test]
167 fn reset_clears_secrets() {
168 let mut state = SecretsManagerState::new("123456789012", "us-east-1");
169 state.secrets.insert(
170 "s1".to_string(),
171 Secret {
172 name: "s1".to_string(),
173 arn: "arn".to_string(),
174 description: None,
175 kms_key_id: None,
176 versions: BTreeMap::new(),
177 current_version_id: None,
178 tags: vec![],
179 tags_ever_set: false,
180 deleted: false,
181 deletion_date: None,
182 created_at: Utc::now(),
183 last_changed_at: Utc::now(),
184 last_accessed_at: None,
185 rotation_enabled: None,
186 rotation_lambda_arn: None,
187 rotation_rules: None,
188 last_rotated_at: None,
189 resource_policy: None,
190 replica_regions: Vec::new(),
191 },
192 );
193 state.reset();
194 assert!(state.secrets.is_empty());
195 }
196
197 fn secret(name: &str, arn: &str) -> Secret {
198 Secret {
199 name: name.to_string(),
200 arn: arn.to_string(),
201 description: None,
202 kms_key_id: None,
203 versions: BTreeMap::new(),
204 current_version_id: None,
205 tags: vec![],
206 tags_ever_set: false,
207 deleted: false,
208 deletion_date: None,
209 created_at: Utc::now(),
210 last_changed_at: Utc::now(),
211 last_accessed_at: None,
212 rotation_enabled: None,
213 rotation_lambda_arn: None,
214 rotation_rules: None,
215 last_rotated_at: None,
216 resource_policy: None,
217 replica_regions: Vec::new(),
218 }
219 }
220
221 #[test]
222 fn clear_name_for_create_purges_expired_and_blocks_pending() {
223 let mut state = SecretsManagerState::new("123456789012", "us-east-1");
224 let now = Utc::now();
225 let mut pending = secret("pending", "arn-p");
226 pending.deleted = true;
227 pending.deletion_date = Some(now + chrono::Duration::days(1));
228 let mut expired = secret("expired", "arn-e");
229 expired.deleted = true;
230 expired.deletion_date = Some(now - chrono::Duration::seconds(1));
231 state.secrets.insert("pending".into(), pending);
232 state.secrets.insert("expired".into(), expired);
233 state.secrets.insert("live".into(), secret("live", "arn-l"));
234
235 let err = state
236 .clear_name_for_create("pending", now)
237 .expect_err("recovery window still open");
238 assert_eq!(err.code(), "InvalidRequestException");
239 assert!(state.secrets.contains_key("pending"));
240
241 state.clear_name_for_create("expired", now).unwrap();
242 assert!(!state.secrets.contains_key("expired"));
243
244 let mut undated = secret("undated", "arn-u");
248 undated.deleted = true;
249 undated.deletion_date = None;
250 state.secrets.insert("undated".into(), undated);
251 let err = state
252 .clear_name_for_create("undated", now)
253 .expect_err("no deletion date: still in its recovery window");
254 assert_eq!(err.code(), "InvalidRequestException");
255 assert!(state.secrets.contains_key("undated"));
256
257 state.clear_name_for_create("live", now).unwrap();
259 assert!(state.secrets.contains_key("live"));
260 state.clear_name_for_create("absent", now).unwrap();
261 }
262
263 #[test]
264 fn secret_key_resolves_name_full_and_partial_arn() {
265 let mut state = SecretsManagerState::new("123456789012", "us-east-1");
266 let app = "arn:aws:secretsmanager:us-east-1:123456789012:secret:app-AbC123";
267 let app_db = "arn:aws:secretsmanager:us-east-1:123456789012:secret:app-db-XyZ789";
268 state.secrets.insert("app".into(), secret("app", app));
269 state
270 .secrets
271 .insert("app-db".into(), secret("app-db", app_db));
272 let legacy = "arn:aws:secretsmanager:us-east-1:123456789012:secret:old-QwErTy";
274 state.secrets.insert(legacy.into(), secret("old", legacy));
275
276 assert_eq!(state.secret_key("app").as_deref(), Some("app"));
277 assert_eq!(state.secret_key(app).as_deref(), Some("app"));
278 assert_eq!(state.secret_key(app_db).as_deref(), Some("app-db"));
279 assert_eq!(
280 state
281 .secret_key("arn:aws:secretsmanager:us-east-1:123456789012:secret:app")
282 .as_deref(),
283 Some("app")
284 );
285 assert_eq!(
286 state
287 .secret_key("arn:aws:secretsmanager:us-east-1:123456789012:secret:app-db")
288 .as_deref(),
289 Some("app-db")
290 );
291 assert_eq!(state.secret_key(legacy).as_deref(), Some(legacy));
292 assert_eq!(state.secret_key("missing"), None);
293 assert_eq!(
294 state.secret_key("arn:aws:secretsmanager:us-east-1:123456789012:secret:ap"),
295 None
296 );
297 }
298}