Skip to main content

fakecloud_secretsmanager/
state.rs

1use 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    /// Replica regions added via ReplicateSecretToRegions. Reflected in
27    /// ReplicationStatus on describe/replicate responses.
28    #[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    /// The length of the rotation window, e.g. "2h" / "1d". AWS's modern
36    /// rotation scheduling form alongside ScheduleExpression.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub duration: Option<String>,
39    /// A cron() or rate() expression for the rotation schedule.
40    #[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    /// Make `name` available to a new secret, as CreateSecret does before
74    /// minting one. A secret of that name that is scheduled for deletion and
75    /// whose recovery window has passed is purged; one still inside its
76    /// recovery window blocks the create with AWS's `InvalidRequestException`.
77    /// A live secret of that name is left in place for the caller to handle
78    /// (CreateSecret's idempotency path, or a name-conflict error).
79    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    /// The `secrets` map key (the secret name) for a `SecretId`: a name, a
102    /// full ARN, or a partial ARN (the full ARN without its random
103    /// six-character suffix). Does not apply recovery-window expiry.
104    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
122/// Whether `partial` is `stored` minus its `-XXXXXX` random suffix: the
123/// partial-ARN form AWS accepts. A bare prefix does not count: the partial
124/// ARN of `app` must not resolve `app-db`.
125fn 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/// On-disk snapshot envelope for Secrets Manager state. Versioned so
142/// format changes fail loudly on upgrade.
143#[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        // A secret marked deleted with no deletion date has no elapsed
245        // recovery window, so it still blocks the name (CreateSecret's
246        // behavior).
247        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        // Live and absent names are left for the caller.
258        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        // A legacy entry keyed by its ARN still resolves to its real key.
273        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}