Skip to main content

fakecloud_secretsmanager/
service.rs

1use std::collections::{BTreeMap, HashMap};
2use std::sync::Arc;
3
4use async_trait::async_trait;
5use chrono::Utc;
6use http::StatusCode;
7use serde_json::{json, Value};
8
9use tokio::sync::Mutex as AsyncMutex;
10
11use fakecloud_aws::arn::Arn;
12use fakecloud_core::delivery::DeliveryBus;
13use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
14use fakecloud_core::validation::*;
15use fakecloud_persistence::SnapshotStore;
16
17use crate::state::{
18    RotationRules, Secret, SecretVersion, SecretsManagerSnapshot, SecretsManagerState,
19    SharedSecretsManagerState, SECRETSMANAGER_SNAPSHOT_SCHEMA_VERSION,
20};
21
22/// Information needed to invoke the rotation Lambda after releasing state lock.
23struct RotationInvocation {
24    lambda_arn: String,
25    secret_id: String,
26    client_request_token: String,
27    /// The rotation steps to invoke, in order. A full rotation runs
28    /// createSecret/setSecret/testSecret/finishSecret; RotateImmediately=false
29    /// with a Lambda runs only testSecret to validate the configuration.
30    steps: Vec<&'static str>,
31    /// A temporary AWSPENDING version created purely so a test-only
32    /// (RotateImmediately=false) invocation has something for the Lambda's
33    /// testSecret step to read. Removed once the steps complete.
34    cleanup_pending: Option<PendingCleanup>,
35}
36
37/// Locates a temporary AWSPENDING version to delete after a test-only rotation.
38struct PendingCleanup {
39    account_id: String,
40    secret_name: String,
41    version_id: String,
42}
43
44/// Remove the temporary AWSPENDING version created for a test-only rotation.
45fn remove_rotation_test_pending(state: &SharedSecretsManagerState, cleanup: &PendingCleanup) {
46    let mut accounts = state.write();
47    if let Some(secret) = accounts
48        .get_or_create(&cleanup.account_id)
49        .secrets
50        .get_mut(&cleanup.secret_name)
51    {
52        secret.versions.remove(&cleanup.version_id);
53    }
54}
55
56/// Invoke the rotation Lambda once per step, in order. Stops early if delivery
57/// is not configured for the target function.
58async fn run_rotation_steps(bus: &DeliveryBus, inv: &RotationInvocation) {
59    for step in &inv.steps {
60        let payload = serde_json::json!({
61            "SecretId": inv.secret_id,
62            "ClientRequestToken": inv.client_request_token,
63            "Step": step,
64        });
65        match bus
66            .invoke_lambda(&inv.lambda_arn, &payload.to_string())
67            .await
68        {
69            Some(Ok(_)) => {}
70            Some(Err(e)) => {
71                tracing::warn!(step = step, error = %e, "rotation Lambda invocation failed");
72            }
73            None => {
74                tracing::warn!(
75                    lambda_arn = %inv.lambda_arn,
76                    step = step,
77                    "rotation Lambda delivery not configured; Lambda invocation skipped"
78                );
79                break;
80            }
81        }
82    }
83}
84
85/// Result of an idempotency check against an existing
86/// `ClientRequestToken` / version id.
87pub(crate) enum VersionIdempotency {
88    /// The version id isn't in the secret yet — this is a fresh write.
89    NotFound,
90    /// The version id exists and stores the exact same payload we're
91    /// about to write — callers should return the existing version as
92    /// a successful no-op response.
93    Match,
94    /// The version id exists but stores a different payload — AWS
95    /// surfaces this as a `ResourceExistsException`.
96    Conflict,
97}
98
99pub struct SecretsManagerService {
100    state: SharedSecretsManagerState,
101    delivery_bus: Option<Arc<DeliveryBus>>,
102    snapshot_store: Option<Arc<dyn SnapshotStore>>,
103    snapshot_lock: Arc<AsyncMutex<()>>,
104    kms_hook: Option<Arc<dyn fakecloud_core::delivery::KmsHook>>,
105}
106
107impl SecretsManagerService {
108    pub fn new(state: SharedSecretsManagerState) -> Self {
109        Self {
110            state,
111            delivery_bus: None,
112            snapshot_store: None,
113            snapshot_lock: Arc::new(AsyncMutex::new(())),
114            kms_hook: None,
115        }
116    }
117
118    pub fn with_delivery(mut self, delivery_bus: Arc<DeliveryBus>) -> Self {
119        self.delivery_bus = Some(delivery_bus);
120        self
121    }
122
123    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
124        self.snapshot_store = Some(store);
125        self
126    }
127
128    pub fn with_kms_hook(mut self, hook: Arc<dyn fakecloud_core::delivery::KmsHook>) -> Self {
129        self.kms_hook = Some(hook);
130        self
131    }
132
133    fn maybe_encrypt_secret_string(
134        &self,
135        account_id: &str,
136        region: &str,
137        secret_arn: &str,
138        kms_key_id: Option<&str>,
139        plaintext: Option<String>,
140    ) -> Option<String> {
141        let pt = plaintext?;
142        let (Some(hook), Some(key)) = (&self.kms_hook, kms_key_id) else {
143            return Some(pt);
144        };
145        let key = if key.is_empty() {
146            "aws/secretsmanager"
147        } else {
148            key
149        };
150        let mut ctx = HashMap::new();
151        ctx.insert(
152            "aws:secretsmanager:secretArn".to_string(),
153            secret_arn.to_string(),
154        );
155        match hook.encrypt(
156            account_id,
157            region,
158            key,
159            pt.as_bytes(),
160            "secretsmanager.amazonaws.com",
161            ctx,
162        ) {
163            Ok(ciphertext) => Some(ciphertext),
164            Err(err) => {
165                tracing::warn!(
166                    secret_arn = %secret_arn,
167                    error = %err,
168                    "KMS encrypt failed for secret; storing plaintext"
169                );
170                Some(pt)
171            }
172        }
173    }
174
175    fn maybe_decrypt_secret_string(
176        &self,
177        account_id: &str,
178        secret_arn: &str,
179        kms_key_id: Option<&str>,
180        stored: Option<&str>,
181    ) -> Option<String> {
182        let stored = stored?;
183        let (Some(hook), Some(_)) = (&self.kms_hook, kms_key_id) else {
184            return Some(stored.to_string());
185        };
186        let mut ctx = HashMap::new();
187        ctx.insert(
188            "aws:secretsmanager:secretArn".to_string(),
189            secret_arn.to_string(),
190        );
191        match hook.decrypt(account_id, stored, "secretsmanager.amazonaws.com", ctx) {
192            Ok(bytes) => Some(String::from_utf8_lossy(&bytes).to_string()),
193            Err(_) => Some(stored.to_string()),
194        }
195    }
196
197    /// Persist current state as a snapshot. Held across the
198    /// clone-serialize-write sequence to prevent stale-last writes,
199    /// with serde + file I/O offloaded to the blocking pool.
200    async fn save_snapshot(&self) {
201        save_secretsmanager_snapshot(
202            &self.state,
203            self.snapshot_store.clone(),
204            &self.snapshot_lock,
205        )
206        .await;
207    }
208
209    /// Build a hook that persists the current Secrets Manager state when
210    /// invoked, or `None` in memory mode (no snapshot store). The
211    /// CloudFormation provisioner mutates `state` directly and uses this to
212    /// write a CFN-provisioned secret through to disk, the same way a direct
213    /// mutating API call would.
214    pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
215        let store = self.snapshot_store.clone()?;
216        let state = self.state.clone();
217        let lock = self.snapshot_lock.clone();
218        Some(Arc::new(move || {
219            let state = state.clone();
220            let store = store.clone();
221            let lock = lock.clone();
222            Box::pin(async move {
223                save_secretsmanager_snapshot(&state, Some(store), &lock).await;
224            })
225        }))
226    }
227
228    fn create_secret(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
229        let input = CreateSecretInput::from_body(&req.json_body())?;
230        let has_value = input.secret_string.is_some() || input.secret_binary.is_some();
231
232        let mut accounts = self.state.write();
233        let state = accounts.get_or_create(&req.account_id);
234
235        if let Some(existing) = state.secrets.get(&input.name) {
236            if let Some(ref token) = input.client_request_token {
237                let existing_plaintext = existing.versions.get(token).and_then(|v| {
238                    self.maybe_decrypt_secret_string(
239                        &req.account_id,
240                        &existing.arn,
241                        existing.kms_key_id.as_deref(),
242                        v.secret_string.as_deref(),
243                    )
244                });
245                match check_secret_version_idempotency(
246                    &existing.versions,
247                    token,
248                    existing_plaintext,
249                    &input.secret_string,
250                    &input.secret_binary,
251                ) {
252                    VersionIdempotency::Match => {
253                        let mut response = json!({
254                            "ARN": existing.arn,
255                            "Name": existing.name,
256                            "VersionId": token,
257                        });
258                        if !has_value {
259                            response.as_object_mut().unwrap().remove("VersionId");
260                        }
261                        return Ok(AwsResponse::ok_json(response));
262                    }
263                    VersionIdempotency::Conflict => {
264                        return Err(AwsServiceError::aws_error(
265                            StatusCode::BAD_REQUEST,
266                            "ResourceExistsException",
267                            format!(
268                                "You can't use ClientRequestToken {token} because that value is already in use for a version of secret {}.",
269                                existing.arn
270                            ),
271                        ));
272                    }
273                    VersionIdempotency::NotFound => {}
274                }
275            }
276            return Err(AwsServiceError::aws_error(
277                StatusCode::BAD_REQUEST,
278                "ResourceExistsException",
279                format!(
280                    "The operation failed because the secret {} already exists.",
281                    input.name
282                ),
283            ));
284        }
285
286        let arn = format!(
287            "arn:aws:secretsmanager:{}:{}:secret:{}-{}",
288            req.region,
289            req.account_id,
290            input.name,
291            &uuid::Uuid::new_v4().to_string()[..6]
292        );
293
294        let now = Utc::now();
295
296        let (versions, current_version_id, version_id_for_response) = if has_value {
297            let vid = input
298                .client_request_token
299                .clone()
300                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
301            let stored_string = self.maybe_encrypt_secret_string(
302                &req.account_id,
303                &req.region,
304                &arn,
305                input.kms_key_id.as_deref(),
306                input.secret_string,
307            );
308            let version = SecretVersion {
309                version_id: vid.clone(),
310                secret_string: stored_string,
311                secret_binary: input.secret_binary,
312                stages: vec!["AWSCURRENT".to_string()],
313                created_at: now,
314            };
315            let mut versions = std::collections::BTreeMap::new();
316            versions.insert(vid.clone(), version);
317            (versions, Some(vid.clone()), Some(vid))
318        } else {
319            (std::collections::BTreeMap::new(), None, None)
320        };
321
322        let tags_ever_set = !input.tags.is_empty();
323        let replica_regions = input.add_replica_regions.clone();
324        let secret = Secret {
325            name: input.name.clone(),
326            arn: arn.clone(),
327            description: input.description,
328            kms_key_id: input.kms_key_id,
329            versions,
330            current_version_id,
331            tags: input.tags,
332            tags_ever_set,
333            deleted: false,
334            deletion_date: None,
335            created_at: now,
336            last_changed_at: now,
337            last_accessed_at: None,
338            rotation_enabled: None,
339            rotation_lambda_arn: None,
340            rotation_rules: None,
341            last_rotated_at: None,
342            resource_policy: None,
343            replica_regions,
344        };
345
346        state.secrets.insert(input.name.clone(), secret);
347
348        let mut response = json!({
349            "ARN": arn,
350            "Name": input.name,
351        });
352        if let Some(vid) = version_id_for_response {
353            response["VersionId"] = json!(vid);
354        }
355        // CreateSecret echoes ReplicationStatus when replica regions were
356        // requested via AddReplicaRegions.
357        if !input.add_replica_regions.is_empty() {
358            response["ReplicationStatus"] = replication_status_json(&input.add_replica_regions);
359        }
360
361        Ok(AwsResponse::ok_json(response))
362    }
363
364    fn get_secret_value(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
365        let body = req.json_body();
366        let secret_id = require_secret_id(&body)?;
367        validate_optional_string_length("versionId", body["VersionId"].as_str(), 32, 64)?;
368        validate_optional_string_length("versionStage", body["VersionStage"].as_str(), 1, 256)?;
369
370        // Resolve owning account from an ARN form. Cross-account
371        // GetSecretValue then evaluates `secret.resource_policy` via
372        // the IAM evaluator before returning the value.
373        let owner_account = secret_owner_account(&secret_id, &req.account_id);
374        let mut accounts = self.state.write();
375        let state = accounts.get_or_create(&owner_account);
376        let secret = self.find_secret_mut(state, &secret_id)?;
377        if owner_account != req.account_id {
378            let policy_doc = secret.resource_policy.as_deref().unwrap_or("");
379            let secret_arn = secret.arn.clone();
380            if !resource_policy_allows(policy_doc, &req.account_id, &secret_arn) {
381                return Err(AwsServiceError::aws_error(
382                    StatusCode::FORBIDDEN,
383                    "AccessDeniedException",
384                    "User is not authorized to perform: secretsmanager:GetSecretValue on the requested resource",
385                ));
386            }
387        }
388
389        if secret.deleted {
390            return Err(AwsServiceError::aws_error(
391                StatusCode::BAD_REQUEST,
392                "InvalidRequestException",
393                "You can't perform this operation on the secret because it was marked for deletion.",
394            ));
395        }
396
397        let requested_stage = body["VersionStage"].as_str().unwrap_or("AWSCURRENT");
398
399        // Determine which version to return
400        let version_id = body["VersionId"]
401            .as_str()
402            .map(|s| s.to_string())
403            .or_else(|| {
404                secret
405                    .versions
406                    .iter()
407                    .find(|(_, v)| v.stages.contains(&requested_stage.to_string()))
408                    .map(|(id, _)| id.clone())
409            });
410
411        let version_id = match version_id {
412            Some(vid) => vid,
413            None => {
414                // No versions exist
415                return Err(AwsServiceError::aws_error(
416                    StatusCode::NOT_FOUND,
417                    "ResourceNotFoundException",
418                    format!(
419                        "Secrets Manager can't find the specified secret value for staging label: {requested_stage}"
420                    ),
421                ));
422            }
423        };
424
425        let version = secret.versions.get(&version_id).ok_or_else(|| {
426            AwsServiceError::aws_error(
427                StatusCode::NOT_FOUND,
428                "ResourceNotFoundException",
429                format!(
430                    "Secrets Manager can't find the specified secret value for VersionId: {version_id}"
431                ),
432            )
433        })?;
434
435        // If VersionStage is specified with VersionId, verify they match
436        if body["VersionId"].as_str().is_some() {
437            if let Some(stage) = body["VersionStage"].as_str() {
438                if !version.stages.contains(&stage.to_string()) {
439                    return Err(AwsServiceError::aws_error(
440                        StatusCode::NOT_FOUND,
441                        "ResourceNotFoundException",
442                        "You provided a VersionStage that is not associated to the provided VersionId.",
443                    ));
444                }
445            }
446        }
447
448        // Only set last_accessed_at on successful retrieval
449        secret.last_accessed_at = Some(Utc::now());
450
451        let mut response = json!({
452            "ARN": secret.arn,
453            "Name": secret.name,
454            "VersionId": version.version_id,
455            "VersionStages": version.stages,
456            "CreatedDate": version.created_at.timestamp_millis() as f64 / 1000.0,
457        });
458
459        let kms_for_decrypt = secret.kms_key_id.clone();
460        let arn_for_decrypt = secret.arn.clone();
461        if let Some(ref s) = version.secret_string {
462            let plaintext = self
463                .maybe_decrypt_secret_string(
464                    &req.account_id,
465                    &arn_for_decrypt,
466                    kms_for_decrypt.as_deref(),
467                    Some(s.as_str()),
468                )
469                .unwrap_or_else(|| s.clone());
470            response["SecretString"] = json!(plaintext);
471        }
472        if let Some(ref b) = version.secret_binary {
473            response["SecretBinary"] = json!(base64_encode(b));
474        }
475
476        Ok(AwsResponse::ok_json(response))
477    }
478
479    fn put_secret_value(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
480        let body = req.json_body();
481        let secret_id = require_secret_id(&body)?;
482        validate_optional_string_length(
483            "clientRequestToken",
484            body["ClientRequestToken"].as_str(),
485            32,
486            64,
487        )?;
488        validate_optional_string_length("secretString", body["SecretString"].as_str(), 1, 65536)?;
489
490        let secret_string = body["SecretString"].as_str().map(|s| s.to_string());
491        let secret_binary = parse_secret_binary(&body)?;
492
493        // Validate that either SecretString or SecretBinary is provided
494        if secret_string.is_none() && secret_binary.is_none() {
495            return Err(AwsServiceError::aws_error(
496                StatusCode::BAD_REQUEST,
497                "InvalidRequestException",
498                "You must provide either SecretString or SecretBinary.",
499            ));
500        }
501
502        let mut accounts = self.state.write();
503        let state = accounts.get_or_create(&req.account_id);
504        let secret = match self.find_secret_mut(state, &secret_id) {
505            Ok(s) => s,
506            Err(_) => {
507                return Err(AwsServiceError::aws_error(
508                    StatusCode::NOT_FOUND,
509                    "ResourceNotFoundException",
510                    "Secrets Manager can't find the specified secret.",
511                ));
512            }
513        };
514
515        if secret.deleted {
516            return Err(AwsServiceError::aws_error(
517                StatusCode::BAD_REQUEST,
518                "InvalidRequestException",
519                "You can't perform this operation on the secret because it was marked for deletion.",
520            ));
521        }
522
523        let now = Utc::now();
524        let version_id = body["ClientRequestToken"]
525            .as_str()
526            .map(|s| s.to_string())
527            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
528
529        let existing_plaintext = secret.versions.get(&version_id).and_then(|v| {
530            self.maybe_decrypt_secret_string(
531                &req.account_id,
532                &secret.arn,
533                secret.kms_key_id.as_deref(),
534                v.secret_string.as_deref(),
535            )
536        });
537        match check_secret_version_idempotency(
538            &secret.versions,
539            &version_id,
540            existing_plaintext,
541            &secret_string,
542            &secret_binary,
543        ) {
544            VersionIdempotency::Match => {
545                let existing_stages = secret.versions[&version_id].stages.clone();
546                return Ok(AwsResponse::ok_json(json!({
547                    "ARN": secret.arn,
548                    "Name": secret.name,
549                    "VersionId": version_id,
550                    "VersionStages": existing_stages,
551                })));
552            }
553            VersionIdempotency::Conflict => {
554                return Err(AwsServiceError::aws_error(
555                    StatusCode::BAD_REQUEST,
556                    "ResourceExistsException",
557                    format!(
558                        "You can't use ClientRequestToken {version_id} because that value is already in use for a version of secret {}.",
559                        secret.arn
560                    ),
561                ));
562            }
563            VersionIdempotency::NotFound => {}
564        }
565
566        let mut version_stages: Vec<String> = body["VersionStages"]
567            .as_array()
568            .map(|arr| {
569                arr.iter()
570                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
571                    .collect()
572            })
573            .unwrap_or_else(|| vec!["AWSCURRENT".to_string()]);
574
575        // If this is the first version with a value, add AWSCURRENT to stages
576        let has_current = secret
577            .versions
578            .values()
579            .any(|v| v.stages.contains(&"AWSCURRENT".to_string()));
580        if !has_current && !version_stages.contains(&"AWSCURRENT".to_string()) {
581            version_stages.push("AWSCURRENT".to_string());
582        }
583
584        // Move AWSCURRENT from old version to AWSPREVIOUS if new version has AWSCURRENT
585        if version_stages.contains(&"AWSCURRENT".to_string()) {
586            if let Some(ref old_vid) = secret.current_version_id.clone() {
587                demote_current_to_previous(&mut secret.versions, old_vid);
588            }
589            secret.current_version_id = Some(version_id.clone());
590        }
591
592        // Remove custom stages from other versions that have them
593        for stage in &version_stages {
594            if stage == "AWSCURRENT" || stage == "AWSPREVIOUS" {
595                continue;
596            }
597            for v in secret.versions.values_mut() {
598                v.stages.retain(|s| s != stage);
599            }
600        }
601
602        // Remove versions with no stages
603        secret.versions.retain(|_, v| !v.stages.is_empty());
604
605        let kms_key_for_enc = secret.kms_key_id.clone();
606        let arn_for_enc = secret.arn.clone();
607        let stored_secret_string = self.maybe_encrypt_secret_string(
608            &req.account_id,
609            &req.region,
610            &arn_for_enc,
611            kms_key_for_enc.as_deref(),
612            secret_string,
613        );
614        let version = SecretVersion {
615            version_id: version_id.clone(),
616            secret_string: stored_secret_string,
617            secret_binary,
618            stages: version_stages.clone(),
619            created_at: now,
620        };
621
622        secret.versions.insert(version_id.clone(), version);
623        secret.last_changed_at = now;
624
625        let response = json!({
626            "ARN": secret.arn,
627            "Name": secret.name,
628            "VersionId": version_id,
629            "VersionStages": version_stages,
630        });
631
632        Ok(AwsResponse::ok_json(response))
633    }
634
635    fn update_secret(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
636        let body = req.json_body();
637        let secret_id = require_secret_id(&body)?;
638        validate_optional_string_length(
639            "clientRequestToken",
640            body["ClientRequestToken"].as_str(),
641            32,
642            64,
643        )?;
644        validate_optional_string_length("description", body["Description"].as_str(), 0, 2048)?;
645        validate_optional_string_length("kmsKeyId", body["KmsKeyId"].as_str(), 0, 2048)?;
646        validate_optional_string_length("secretString", body["SecretString"].as_str(), 1, 65536)?;
647
648        let mut accounts = self.state.write();
649        let state = accounts.get_or_create(&req.account_id);
650        let secret = match self.find_secret_mut(state, &secret_id) {
651            Ok(s) => s,
652            Err(_) => {
653                return Err(AwsServiceError::aws_error(
654                    StatusCode::NOT_FOUND,
655                    "ResourceNotFoundException",
656                    "Secrets Manager can't find the specified secret.",
657                ));
658            }
659        };
660
661        if secret.deleted {
662            return Err(AwsServiceError::aws_error(
663                StatusCode::BAD_REQUEST,
664                "InvalidRequestException",
665                "You can't perform this operation on the secret because it was marked for deletion.",
666            ));
667        }
668
669        if let Some(desc) = body["Description"].as_str() {
670            secret.description = Some(desc.to_string());
671        }
672        if let Some(kms) = body["KmsKeyId"].as_str() {
673            secret.kms_key_id = Some(kms.to_string());
674        }
675
676        // If SecretString or SecretBinary is provided, create a new version
677        let secret_string = body["SecretString"].as_str().map(|s| s.to_string());
678        let secret_binary = parse_secret_binary(&body)?;
679
680        let version_id = if secret_string.is_some() || secret_binary.is_some() {
681            let vid = body["ClientRequestToken"]
682                .as_str()
683                .map(|s| s.to_string())
684                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
685
686            let existing_plaintext = secret.versions.get(&vid).and_then(|v| {
687                self.maybe_decrypt_secret_string(
688                    &req.account_id,
689                    &secret.arn,
690                    secret.kms_key_id.as_deref(),
691                    v.secret_string.as_deref(),
692                )
693            });
694            match check_secret_version_idempotency(
695                &secret.versions,
696                &vid,
697                existing_plaintext,
698                &secret_string,
699                &secret_binary,
700            ) {
701                VersionIdempotency::Match => {
702                    return Ok(AwsResponse::ok_json(json!({
703                        "ARN": secret.arn,
704                        "Name": secret.name,
705                        "VersionId": vid,
706                    })));
707                }
708                VersionIdempotency::Conflict => {
709                    return Err(AwsServiceError::aws_error(
710                        StatusCode::BAD_REQUEST,
711                        "ResourceExistsException",
712                        format!(
713                            "You can't use ClientRequestToken {vid} because that value is already in use for a version of secret {}.",
714                            secret.arn
715                        ),
716                    ));
717                }
718                VersionIdempotency::NotFound => {}
719            }
720
721            let now = Utc::now();
722
723            // Move AWSCURRENT -> AWSPREVIOUS on old version, keeping AWSPREVIOUS
724            // unique across versions.
725            if let Some(ref old_vid) = secret.current_version_id.clone() {
726                demote_current_to_previous(&mut secret.versions, old_vid);
727            }
728
729            let version = SecretVersion {
730                version_id: vid.clone(),
731                secret_string,
732                secret_binary,
733                stages: vec!["AWSCURRENT".to_string()],
734                created_at: now,
735            };
736            secret.versions.insert(vid.clone(), version);
737            secret.current_version_id = Some(vid.clone());
738            secret.last_changed_at = now;
739            Some(vid)
740        } else {
741            secret.last_changed_at = Utc::now();
742            None
743        };
744
745        let mut response = json!({
746            "ARN": secret.arn,
747            "Name": secret.name,
748        });
749        if let Some(vid) = version_id {
750            response["VersionId"] = json!(vid);
751        }
752
753        Ok(AwsResponse::ok_json(response))
754    }
755
756    fn delete_secret(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
757        let body = req.json_body();
758        let secret_id = require_secret_id(&body)?;
759
760        let force_delete = body["ForceDeleteWithoutRecovery"]
761            .as_bool()
762            .unwrap_or(false);
763        let recovery_window = body.get("RecoveryWindowInDays").and_then(|v| v.as_i64());
764
765        // Validate recovery window range first (AWS validates this before the conflict check)
766        if let Some(days) = recovery_window {
767            if !(7..=30).contains(&days) {
768                return Err(AwsServiceError::aws_error(
769                    StatusCode::BAD_REQUEST,
770                    "InvalidParameterException",
771                    "An error occurred (InvalidParameterException) when calling the DeleteSecret operation: RecoveryWindowInDays value must be between 7 and 30 days (inclusive).",
772                ));
773            }
774        }
775
776        // Validate: can't use both force delete and recovery window
777        if force_delete && recovery_window.is_some() {
778            return Err(AwsServiceError::aws_error(
779                StatusCode::BAD_REQUEST,
780                "InvalidParameterException",
781                "An error occurred (InvalidParameterException) when calling the DeleteSecret operation: You can't use ForceDeleteWithoutRecovery in conjunction with RecoveryWindowInDays.",
782            ));
783        }
784
785        let mut accounts = self.state.write();
786        let state = accounts.get_or_create(&req.account_id);
787
788        if force_delete {
789            // Force delete: if secret doesn't exist, create a fake response
790            match self.find_secret_mut(state, &secret_id) {
791                Ok(secret) => {
792                    let arn = secret.arn.clone();
793                    let name = secret.name.clone();
794                    let deletion_date = Utc::now();
795                    state.secrets.remove(&name);
796                    let response = json!({
797                        "ARN": arn,
798                        "Name": name,
799                        "DeletionDate": deletion_date.timestamp_millis() as f64 / 1000.0,
800                    });
801                    return Ok(AwsResponse::ok_json(response));
802                }
803                Err(_) => {
804                    // For force delete of non-existent secret, AWS returns success
805                    let arn = format!(
806                        "arn:aws:secretsmanager:{}:{}:secret:{}-{}",
807                        req.region,
808                        req.account_id,
809                        secret_id,
810                        &uuid::Uuid::new_v4().to_string()[..6]
811                    );
812                    let deletion_date = Utc::now();
813                    let response = json!({
814                        "ARN": arn,
815                        "Name": secret_id,
816                        "DeletionDate": deletion_date.timestamp_millis() as f64 / 1000.0,
817                    });
818                    return Ok(AwsResponse::ok_json(response));
819                }
820            }
821        }
822
823        let secret = self.find_secret_mut(state, &secret_id)?;
824
825        if secret.deleted {
826            return Err(AwsServiceError::aws_error(
827                StatusCode::BAD_REQUEST,
828                "InvalidRequestException",
829                "You can't perform this operation on the secret because it was already scheduled for deletion.",
830            ));
831        }
832
833        let now = Utc::now();
834        let days = recovery_window.unwrap_or(30);
835        let deletion_date = now + chrono::Duration::days(days);
836        secret.deleted = true;
837        secret.deletion_date = Some(deletion_date);
838
839        let response = json!({
840            "ARN": secret.arn,
841            "Name": secret.name,
842            "DeletionDate": deletion_date.timestamp_millis() as f64 / 1000.0,
843        });
844
845        Ok(AwsResponse::ok_json(response))
846    }
847
848    fn restore_secret(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
849        let body = req.json_body();
850        let secret_id = require_secret_id(&body)?;
851
852        let mut accounts = self.state.write();
853        let state = accounts.get_or_create(&req.account_id);
854        let secret = self.find_secret_mut(state, &secret_id)?;
855
856        // AWS allows restoring a secret that is not deleted (no-op)
857        secret.deleted = false;
858        secret.deletion_date = None;
859
860        let response = json!({
861            "ARN": secret.arn,
862            "Name": secret.name,
863        });
864
865        Ok(AwsResponse::ok_json(response))
866    }
867
868    fn describe_secret(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
869        let body = req.json_body();
870        let secret_id = require_secret_id(&body)?;
871
872        let accounts = self.state.read();
873        let empty = SecretsManagerState::new(&req.account_id, &req.region);
874        let state = accounts.get(&req.account_id).unwrap_or(&empty);
875        let secret = self.find_secret_ref(state, &secret_id)?;
876
877        let mut response = json!({
878            "ARN": secret.arn,
879            "Name": secret.name,
880            "CreatedDate": secret.created_at.timestamp_millis() as f64 / 1000.0,
881            "LastChangedDate": secret.last_changed_at.timestamp_millis() as f64 / 1000.0,
882        });
883
884        if !secret.versions.is_empty() {
885            let mut version_ids_to_stages: serde_json::Map<String, Value> = serde_json::Map::new();
886            for (vid, version) in &secret.versions {
887                version_ids_to_stages.insert(vid.clone(), json!(version.stages));
888            }
889            response["VersionIdsToStages"] = Value::Object(version_ids_to_stages);
890        }
891
892        if let Some(ref desc) = secret.description {
893            if !desc.is_empty() {
894                response["Description"] = json!(desc);
895            }
896        }
897
898        if secret.tags_ever_set || !secret.tags.is_empty() {
899            response["Tags"] = json!(tags_to_json(&secret.tags));
900        }
901
902        if let Some(ref kms) = secret.kms_key_id {
903            response["KmsKeyId"] = json!(kms);
904        }
905        if secret.deleted {
906            response["DeletedDate"] = json!(secret
907                .deletion_date
908                .map(|d| d.timestamp_millis() as f64 / 1000.0));
909        }
910        if let Some(rotation_enabled) = secret.rotation_enabled {
911            response["RotationEnabled"] = json!(rotation_enabled);
912        }
913        if let Some(ref lambda_arn) = secret.rotation_lambda_arn {
914            response["RotationLambdaARN"] = json!(lambda_arn);
915        }
916        if let Some(ref rules) = secret.rotation_rules {
917            let mut rules_json = json!({});
918            if let Some(days) = rules.automatically_after_days {
919                rules_json["AutomaticallyAfterDays"] = json!(days);
920            }
921            if let Some(ref duration) = rules.duration {
922                rules_json["Duration"] = json!(duration);
923            }
924            if let Some(ref expr) = rules.schedule_expression {
925                rules_json["ScheduleExpression"] = json!(expr);
926            }
927            response["RotationRules"] = rules_json;
928        }
929        if let Some(last_rotated) = secret.last_rotated_at {
930            response["LastRotatedDate"] = json!(last_rotated.timestamp_millis() as f64 / 1000.0);
931        }
932        if !secret.replica_regions.is_empty() {
933            response["ReplicationStatus"] = replication_status_json(&secret.replica_regions);
934        }
935        // Calculate NextRotationDate if rotation is enabled
936        if secret.rotation_enabled == Some(true) {
937            if let Some(ref rules) = secret.rotation_rules {
938                if let Some(days) = rules.automatically_after_days {
939                    let base = secret.last_rotated_at.unwrap_or(secret.created_at);
940                    let next = base + chrono::Duration::days(days);
941                    response["NextRotationDate"] = json!(next.timestamp_millis() as f64 / 1000.0);
942                }
943            }
944        }
945
946        Ok(AwsResponse::ok_json(response))
947    }
948
949    fn list_secrets(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
950        let body = req.json_body();
951        validate_optional_string_length("nextToken", body["NextToken"].as_str(), 1, 4096)?;
952        validate_optional_range_i64("maxResults", body["MaxResults"].as_i64(), 1, 100)?;
953        validate_optional_enum("sortBy", body["SortBy"].as_str(), &["name", "created-date"])?;
954        validate_optional_enum("sortOrder", body["SortOrder"].as_str(), &["asc", "desc"])?;
955        let max_results = body["MaxResults"].as_i64().unwrap_or(100) as usize;
956        let next_token = body["NextToken"].as_str();
957        let filters = body["Filters"].as_array();
958        let include_deleted = body["IncludePlannedDeletion"].as_bool().unwrap_or(false);
959
960        // Validate filters
961        if let Some(filters) = filters {
962            for filter in filters {
963                let key = filter["Key"].as_str().unwrap_or("");
964                let values = filter["Values"].as_array();
965
966                if key.is_empty() {
967                    return Err(AwsServiceError::aws_error(
968                        StatusCode::BAD_REQUEST,
969                        "InvalidParameterException",
970                        "Invalid filter key",
971                    ));
972                }
973
974                let valid_keys = [
975                    "all",
976                    "name",
977                    "tag-key",
978                    "description",
979                    "tag-value",
980                    "owning-service",
981                    "primary-region",
982                ];
983                if !valid_keys.contains(&key) {
984                    return Err(AwsServiceError::aws_error(
985                        StatusCode::BAD_REQUEST,
986                        "ValidationException",
987                        format!(
988                            "1 validation error detected: Value '{}' at 'filters.1.member.key' failed to satisfy constraint: Member must satisfy enum value set: [all, name, tag-key, description, tag-value]",
989                            key
990                        ),
991                    ));
992                }
993
994                if values.is_none() || values.unwrap().is_empty() {
995                    return Err(AwsServiceError::aws_error(
996                        StatusCode::BAD_REQUEST,
997                        "InvalidParameterException",
998                        format!("Invalid filter values for key: {key}"),
999                    ));
1000                }
1001            }
1002        }
1003
1004        let accounts = self.state.read();
1005        let empty = SecretsManagerState::new(&req.account_id, &req.region);
1006        let state = accounts.get(&req.account_id).unwrap_or(&empty);
1007
1008        let mut secrets: Vec<&Secret> = state
1009            .secrets
1010            .values()
1011            .filter(|s| {
1012                // Exclude deleted unless IncludePlannedDeletion
1013                if s.deleted && !include_deleted {
1014                    return false;
1015                }
1016
1017                if let Some(filters) = filters {
1018                    for filter in filters {
1019                        let key = filter["Key"].as_str().unwrap_or("");
1020                        let values: Vec<&str> = filter["Values"]
1021                            .as_array()
1022                            .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
1023                            .unwrap_or_default();
1024
1025                        let matches = match key {
1026                            "name" => filter_name(s, &values),
1027                            "description" => filter_description(s, &values),
1028                            "tag-key" => filter_tag_key(s, &values),
1029                            "tag-value" => filter_tag_value(s, &values),
1030                            "all" => filter_all(s, &values),
1031                            "owning-service" => false,
1032                            "primary-region" => false,
1033                            _ => true,
1034                        };
1035
1036                        if !matches {
1037                            return false;
1038                        }
1039                    }
1040                }
1041                true
1042            })
1043            .collect();
1044        secrets.sort_by_key(|a| a.created_at);
1045
1046        // Simple pagination with name-based token. When the token's secret was
1047        // deleted between pages the lookup fails; end the listing (empty page,
1048        // no token) rather than restarting at offset 0 (which could loop).
1049        let start_idx = if let Some(token) = next_token {
1050            secrets
1051                .iter()
1052                .position(|s| s.name == token)
1053                .unwrap_or(secrets.len())
1054        } else {
1055            0
1056        };
1057
1058        let page: Vec<Value> = secrets
1059            .iter()
1060            .skip(start_idx)
1061            .take(max_results)
1062            .map(|s| {
1063                // AWS always echoes `Description` and `SecretVersionsToStages`
1064                // on every SecretListEntry. The documented `@examples` for
1065                // ListSecrets relies on the fields being present even when
1066                // empty, and SDK consumers index into them unconditionally.
1067                let mut version_ids_to_stages: serde_json::Map<String, Value> =
1068                    serde_json::Map::new();
1069                for (vid, version) in &s.versions {
1070                    version_ids_to_stages.insert(vid.clone(), json!(version.stages));
1071                }
1072                let mut entry = json!({
1073                    "ARN": s.arn,
1074                    "Name": s.name,
1075                    "CreatedDate": s.created_at.timestamp_millis() as f64 / 1000.0,
1076                    "LastChangedDate": s.last_changed_at.timestamp_millis() as f64 / 1000.0,
1077                    "Description": s.description.clone().unwrap_or_default(),
1078                    "SecretVersionsToStages": Value::Object(version_ids_to_stages),
1079                });
1080
1081                if s.tags_ever_set || !s.tags.is_empty() {
1082                    entry["Tags"] = json!(tags_to_json(&s.tags));
1083                }
1084
1085                if let Some(ref kms) = s.kms_key_id {
1086                    entry["KmsKeyId"] = json!(kms);
1087                }
1088                if s.deleted {
1089                    entry["DeletedDate"] = json!(s
1090                        .deletion_date
1091                        .map(|d| d.timestamp_millis() as f64 / 1000.0));
1092                }
1093                entry
1094            })
1095            .collect();
1096
1097        let has_more = start_idx + max_results < secrets.len();
1098        let mut response = json!({
1099            "SecretList": page,
1100        });
1101        if has_more {
1102            if let Some(next) = secrets.get(start_idx + max_results) {
1103                response["NextToken"] = json!(next.name);
1104            }
1105        }
1106
1107        Ok(AwsResponse::ok_json(response))
1108    }
1109
1110    fn tag_resource(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1111        let body = req.json_body();
1112        let secret_id = require_secret_id(&body)?;
1113
1114        let new_tags = parse_tags(&body["Tags"]);
1115
1116        let mut accounts = self.state.write();
1117        let state = accounts.get_or_create(&req.account_id);
1118        let secret = self.find_secret_mut(state, &secret_id)?;
1119
1120        if !new_tags.is_empty() {
1121            secret.tags_ever_set = true;
1122        }
1123        for (k, v) in new_tags {
1124            // Update existing tag or add new one
1125            if let Some(existing) = secret.tags.iter_mut().find(|(ek, _)| *ek == k) {
1126                existing.1 = v;
1127            } else {
1128                secret.tags.push((k, v));
1129            }
1130        }
1131
1132        Ok(AwsResponse::json(StatusCode::OK, "{}"))
1133    }
1134
1135    fn untag_resource(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1136        let body = req.json_body();
1137        let secret_id = require_secret_id(&body)?;
1138
1139        let tag_keys: Vec<String> = body["TagKeys"]
1140            .as_array()
1141            .map(|arr| {
1142                arr.iter()
1143                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
1144                    .collect()
1145            })
1146            .unwrap_or_default();
1147
1148        let mut accounts = self.state.write();
1149        let state = accounts.get_or_create(&req.account_id);
1150        let secret = self.find_secret_mut(state, &secret_id)?;
1151
1152        secret.tags.retain(|(k, _)| !tag_keys.contains(k));
1153
1154        Ok(AwsResponse::json(StatusCode::OK, "{}"))
1155    }
1156
1157    fn list_secret_version_ids(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1158        let body = req.json_body();
1159        let secret_id = require_secret_id(&body)?;
1160        validate_optional_string_length("nextToken", body["NextToken"].as_str(), 1, 4096)?;
1161        validate_optional_range_i64("maxResults", body["MaxResults"].as_i64(), 1, 100)?;
1162        let max_results = body["MaxResults"].as_i64().unwrap_or(100) as usize;
1163        let next_token = body["NextToken"].as_str();
1164
1165        let accounts = self.state.read();
1166        let empty = SecretsManagerState::new(&req.account_id, &req.region);
1167        let state = accounts.get(&req.account_id).unwrap_or(&empty);
1168        let secret = self.find_secret_ref(state, &secret_id)?;
1169
1170        // Stable order so the NextToken (a version id) resumes deterministically:
1171        // newest first by CreatedDate, version id as a tiebreaker.
1172        let mut versions: Vec<&_> = secret.versions.values().collect();
1173        versions.sort_by(|a, b| {
1174            b.created_at
1175                .cmp(&a.created_at)
1176                .then_with(|| a.version_id.cmp(&b.version_id))
1177        });
1178
1179        let start_idx = if let Some(token) = next_token {
1180            versions
1181                .iter()
1182                .position(|v| v.version_id == token)
1183                .unwrap_or(versions.len())
1184        } else {
1185            0
1186        };
1187
1188        let page: Vec<Value> = versions
1189            .iter()
1190            .skip(start_idx)
1191            .take(max_results)
1192            .map(|v| {
1193                json!({
1194                    "VersionId": v.version_id,
1195                    "VersionStages": v.stages,
1196                    "CreatedDate": v.created_at.timestamp_millis() as f64 / 1000.0,
1197                })
1198            })
1199            .collect();
1200
1201        let mut response = json!({
1202            "ARN": secret.arn,
1203            "Name": secret.name,
1204            "Versions": page,
1205        });
1206        if start_idx + max_results < versions.len() {
1207            if let Some(next) = versions.get(start_idx + max_results) {
1208                response["NextToken"] = json!(next.version_id);
1209            }
1210        }
1211
1212        Ok(AwsResponse::ok_json(response))
1213    }
1214
1215    fn get_random_password(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1216        let body = req.json_body();
1217        let length = body["PasswordLength"].as_i64().unwrap_or(32) as usize;
1218
1219        if length < 4 {
1220            return Err(AwsServiceError::aws_error(
1221                StatusCode::BAD_REQUEST,
1222                "InvalidParameterException",
1223                "Invalid value for parameter PasswordLength; must be 4..=4096.",
1224            ));
1225        }
1226        if length > 4096 {
1227            return Err(AwsServiceError::aws_error(
1228                StatusCode::BAD_REQUEST,
1229                "InvalidParameterException",
1230                "Invalid value for parameter PasswordLength; must be 4..=4096.",
1231            ));
1232        }
1233
1234        let exclude_lowercase = body["ExcludeLowercase"].as_bool().unwrap_or(false);
1235        let exclude_uppercase = body["ExcludeUppercase"].as_bool().unwrap_or(false);
1236        let exclude_numbers = body["ExcludeNumbers"].as_bool().unwrap_or(false);
1237        let exclude_punctuation = body["ExcludePunctuation"].as_bool().unwrap_or(false);
1238        let include_space = body["IncludeSpace"].as_bool().unwrap_or(false);
1239        let require_each = body["RequireEachIncludedType"].as_bool().unwrap_or(true);
1240        validate_optional_string_length(
1241            "excludeCharacters",
1242            body["ExcludeCharacters"].as_str(),
1243            0,
1244            4096,
1245        )?;
1246        let exclude_chars = body["ExcludeCharacters"].as_str().unwrap_or("").to_string();
1247
1248        let lowercase = "abcdefghijklmnopqrstuvwxyz";
1249        let uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
1250        let digits = "0123456789";
1251        let punctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
1252
1253        let mut char_pool = String::new();
1254        let mut required_chars: Vec<String> = Vec::new();
1255
1256        if !exclude_lowercase {
1257            let filtered: String = lowercase
1258                .chars()
1259                .filter(|c| !exclude_chars.contains(*c))
1260                .collect();
1261            if !filtered.is_empty() {
1262                required_chars.push(filtered.clone());
1263                char_pool.push_str(&filtered);
1264            }
1265        }
1266        if !exclude_uppercase {
1267            let filtered: String = uppercase
1268                .chars()
1269                .filter(|c| !exclude_chars.contains(*c))
1270                .collect();
1271            if !filtered.is_empty() {
1272                required_chars.push(filtered.clone());
1273                char_pool.push_str(&filtered);
1274            }
1275        }
1276        if !exclude_numbers {
1277            let filtered: String = digits
1278                .chars()
1279                .filter(|c| !exclude_chars.contains(*c))
1280                .collect();
1281            if !filtered.is_empty() {
1282                required_chars.push(filtered.clone());
1283                char_pool.push_str(&filtered);
1284            }
1285        }
1286        if !exclude_punctuation {
1287            let filtered: String = punctuation
1288                .chars()
1289                .filter(|c| !exclude_chars.contains(*c))
1290                .collect();
1291            if !filtered.is_empty() {
1292                required_chars.push(filtered.clone());
1293                char_pool.push_str(&filtered);
1294            }
1295        }
1296        if include_space && !exclude_chars.contains(' ') {
1297            char_pool.push(' ');
1298        }
1299
1300        if char_pool.is_empty() {
1301            return Err(AwsServiceError::aws_error(
1302                StatusCode::BAD_REQUEST,
1303                "InvalidParameterException",
1304                "InvalidParameterException",
1305            ));
1306        }
1307
1308        let pool_bytes: Vec<char> = char_pool.chars().collect();
1309        let mut password = String::with_capacity(length);
1310
1311        // Use simple random generation
1312        if require_each {
1313            // Each required class (plus space, when included) consumes one
1314            // guaranteed slot. If the requested length can't hold one of each,
1315            // AWS rejects the request rather than silently dropping a class.
1316            let mut required_count = required_chars.len();
1317            if include_space && !exclude_chars.contains(' ') {
1318                required_count += 1;
1319            }
1320            if length < required_count {
1321                return Err(AwsServiceError::aws_error(
1322                    StatusCode::BAD_REQUEST,
1323                    "InvalidParameterException",
1324                    "PasswordLength is too short to include one of each required character type.",
1325                ));
1326            }
1327            // First, ensure at least one character from each required category
1328            for category in &required_chars {
1329                let chars: Vec<char> = category.chars().collect();
1330                let idx = simple_random() % chars.len();
1331                password.push(chars[idx]);
1332            }
1333            if include_space && !exclude_chars.contains(' ') {
1334                password.push(' ');
1335            }
1336        }
1337
1338        // Fill the rest randomly
1339        while password.len() < length {
1340            let idx = simple_random() % pool_bytes.len();
1341            password.push(pool_bytes[idx]);
1342        }
1343
1344        // Shuffle the password (Fisher-Yates)
1345        let mut chars: Vec<char> = password.chars().collect();
1346        for i in (1..chars.len()).rev() {
1347            let j = simple_random() % (i + 1);
1348            chars.swap(i, j);
1349        }
1350        let password: String = chars.into_iter().take(length).collect();
1351
1352        let response = json!({
1353            "RandomPassword": password,
1354        });
1355
1356        Ok(AwsResponse::ok_json(response))
1357    }
1358
1359    fn rotate_secret(
1360        &self,
1361        req: &AwsRequest,
1362    ) -> Result<(AwsResponse, Option<RotationInvocation>), AwsServiceError> {
1363        let body = req.json_body();
1364        let secret_id = require_secret_id(&body)?;
1365
1366        // Validate ClientRequestToken
1367        if let Some(token) = body["ClientRequestToken"].as_str() {
1368            if token.len() < 32 || token.len() > 64 {
1369                return Err(AwsServiceError::aws_error(
1370                    StatusCode::BAD_REQUEST,
1371                    "InvalidParameterException",
1372                    "ClientRequestToken must be 32-64 characters long.",
1373                ));
1374            }
1375        }
1376
1377        // Validate RotationLambdaARN
1378        if let Some(arn) = body["RotationLambdaARN"].as_str() {
1379            if arn.len() > 2048 {
1380                return Err(AwsServiceError::aws_error(
1381                    StatusCode::BAD_REQUEST,
1382                    "InvalidParameterException",
1383                    "RotationLambdaARN length must be less than or equal to 2048.",
1384                ));
1385            }
1386        }
1387
1388        // Validate RotationRules
1389        if let Some(rules) = body["RotationRules"].as_object() {
1390            if let Some(days) = rules.get("AutomaticallyAfterDays").and_then(|v| v.as_i64()) {
1391                if !(1..=1000).contains(&days) {
1392                    return Err(AwsServiceError::aws_error(
1393                        StatusCode::BAD_REQUEST,
1394                        "InvalidParameterException",
1395                        "RotationRules.AutomaticallyAfterDays must be within 1-1000.",
1396                    ));
1397                }
1398            }
1399        }
1400
1401        let mut accounts = self.state.write();
1402        let state = accounts.get_or_create(&req.account_id);
1403        let secret = self.find_secret_mut(state, &secret_id)?;
1404
1405        if secret.deleted {
1406            return Err(AwsServiceError::aws_error(
1407                StatusCode::BAD_REQUEST,
1408                "InvalidRequestException",
1409                "You can't perform this operation on the secret because it was marked for deletion.",
1410            ));
1411        }
1412
1413        // Set rotation config
1414        if let Some(lambda_arn) = body["RotationLambdaARN"].as_str() {
1415            secret.rotation_lambda_arn = Some(lambda_arn.to_string());
1416        }
1417
1418        if let Some(rules) = body["RotationRules"].as_object() {
1419            let days = rules.get("AutomaticallyAfterDays").and_then(|v| v.as_i64());
1420            secret.rotation_rules = Some(RotationRules {
1421                automatically_after_days: days,
1422                duration: rules
1423                    .get("Duration")
1424                    .and_then(|v| v.as_str())
1425                    .map(String::from),
1426                schedule_expression: rules
1427                    .get("ScheduleExpression")
1428                    .and_then(|v| v.as_str())
1429                    .map(String::from),
1430            });
1431        }
1432
1433        secret.rotation_enabled = Some(true);
1434        let now = Utc::now();
1435        secret.last_changed_at = now;
1436
1437        // RotateImmediately defaults to true. When false, Secrets Manager
1438        // saves the rotation configuration and schedules the next rotation
1439        // for the upcoming window WITHOUT rotating the value now (and
1440        // without invoking the rotation Lambda). LastRotatedDate is only
1441        // set when a rotation actually happens.
1442        let rotate_immediately = body["RotateImmediately"].as_bool().unwrap_or(true);
1443
1444        let version_id = body["ClientRequestToken"]
1445            .as_str()
1446            .map(|s| s.to_string())
1447            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1448
1449        let has_lambda =
1450            body["RotationLambdaARN"].as_str().is_some() || secret.rotation_lambda_arn.is_some();
1451        let lambda_arn = secret.rotation_lambda_arn.clone();
1452
1453        // Rotation behavior:
1454        // - RotateImmediately (default): a full rotation. With a Lambda, the
1455        //   Lambda runs all four steps; without one, the value is rotated
1456        //   in-place (new AWSCURRENT, old -> AWSPREVIOUS).
1457        // - RotateImmediately=false: the value is NOT rotated now. But when a
1458        //   rotation Lambda is configured, AWS still validates the rotation
1459        //   configuration by running ONLY the testSecret step (matching real
1460        //   Secrets Manager). LastRotatedDate is only set on an actual rotation.
1461        let mut invocation = None;
1462        if rotate_immediately {
1463            if let Some(current_vid) = secret.current_version_id.clone() {
1464                let current_value = secret.versions.get(&current_vid).cloned();
1465
1466                if let Some(cv) = current_value {
1467                    if has_lambda {
1468                        // With Lambda: do NOT pre-create the AWSPENDING version. The
1469                        // rotation Lambda is responsible for putting the new value via
1470                        // PutSecretValue with VersionStages=[AWSPENDING] during the
1471                        // createSecret step (matching real AWS Secrets Manager behavior).
1472
1473                        // Schedule Lambda invocation (full rotation steps).
1474                        if let Some(ref arn) = lambda_arn {
1475                            invocation = Some(RotationInvocation {
1476                                lambda_arn: arn.clone(),
1477                                secret_id: secret.arn.clone(),
1478                                client_request_token: version_id.clone(),
1479                                steps: vec![
1480                                    "createSecret",
1481                                    "setSecret",
1482                                    "testSecret",
1483                                    "finishSecret",
1484                                ],
1485                                cleanup_pending: None,
1486                            });
1487                        }
1488                    } else {
1489                        // Without Lambda: simple rotation - new version becomes AWSCURRENT.
1490                        // Move old version to AWSPREVIOUS (kept unique across versions).
1491                        demote_current_to_previous(&mut secret.versions, &current_vid);
1492                        let version = SecretVersion {
1493                            version_id: version_id.clone(),
1494                            secret_string: cv.secret_string.clone(),
1495                            secret_binary: cv.secret_binary.clone(),
1496                            stages: vec!["AWSCURRENT".to_string()],
1497                            created_at: now,
1498                        };
1499                        secret.versions.insert(version_id.clone(), version);
1500                        secret.current_version_id = Some(version_id.clone());
1501                        // The value has now actually rotated (synchronously), so
1502                        // this is the point where LastRotatedDate is stamped. The
1503                        // Lambda path does not claim completion here because the
1504                        // rotation runs asynchronously and may not succeed.
1505                        secret.last_rotated_at = Some(now);
1506                    }
1507                }
1508            }
1509        } else if has_lambda {
1510            // RotateImmediately=false with a Lambda: AWS validates the rotation
1511            // configuration by running only the testSecret step. The current
1512            // value is NOT rotated and LastRotatedDate is left untouched, but
1513            // the Lambda's testSecret step needs an AWSPENDING version to read.
1514            // Mirror AWS by staging a temporary AWSPENDING copy of the current
1515            // value (keyed by the rotation token) and removing it once the test
1516            // completes.
1517            if let (Some(arn), Some(current_vid)) =
1518                (lambda_arn.as_ref(), secret.current_version_id.clone())
1519            {
1520                if let Some(cv) = secret.versions.get(&current_vid).cloned() {
1521                    secret.versions.insert(
1522                        version_id.clone(),
1523                        SecretVersion {
1524                            version_id: version_id.clone(),
1525                            secret_string: cv.secret_string.clone(),
1526                            secret_binary: cv.secret_binary.clone(),
1527                            stages: vec!["AWSPENDING".to_string()],
1528                            created_at: now,
1529                        },
1530                    );
1531                    invocation = Some(RotationInvocation {
1532                        lambda_arn: arn.clone(),
1533                        secret_id: secret.arn.clone(),
1534                        client_request_token: version_id.clone(),
1535                        steps: vec!["testSecret"],
1536                        cleanup_pending: Some(PendingCleanup {
1537                            account_id: req.account_id.clone(),
1538                            secret_name: secret.name.clone(),
1539                            version_id: version_id.clone(),
1540                        }),
1541                    });
1542                }
1543            }
1544        }
1545
1546        let response = json!({
1547            "ARN": secret.arn,
1548            "Name": secret.name,
1549            "VersionId": version_id,
1550        });
1551
1552        Ok((AwsResponse::ok_json(response), invocation))
1553    }
1554
1555    fn cancel_rotate_secret(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1556        let body = req.json_body();
1557        let secret_id = require_secret_id(&body)?;
1558
1559        let mut accounts = self.state.write();
1560        let state = accounts.get_or_create(&req.account_id);
1561        let secret = self.find_secret_mut(state, &secret_id)?;
1562
1563        if secret.deleted {
1564            return Err(AwsServiceError::aws_error(
1565                StatusCode::BAD_REQUEST,
1566                "InvalidRequestException",
1567                "You can't perform this operation on the secret because it was marked for deletion.",
1568            ));
1569        }
1570
1571        if secret.rotation_enabled != Some(true) {
1572            return Err(AwsServiceError::aws_error(
1573                StatusCode::BAD_REQUEST,
1574                "InvalidRequestException",
1575                "You can't cancel rotation for a secret that does not have rotation enabled.",
1576            ));
1577        }
1578
1579        secret.rotation_enabled = Some(false);
1580
1581        let response = json!({
1582            "ARN": secret.arn,
1583            "Name": secret.name,
1584        });
1585
1586        Ok(AwsResponse::ok_json(response))
1587    }
1588
1589    fn update_secret_version_stage(
1590        &self,
1591        req: &AwsRequest,
1592    ) -> Result<AwsResponse, AwsServiceError> {
1593        let body = req.json_body();
1594        let secret_id = require_secret_id(&body)?;
1595        let version_stage = body["VersionStage"]
1596            .as_str()
1597            .ok_or_else(|| {
1598                AwsServiceError::aws_error(
1599                    StatusCode::BAD_REQUEST,
1600                    "InvalidParameterException",
1601                    "VersionStage is required",
1602                )
1603            })?
1604            .to_string();
1605        validate_string_length("versionStage", &version_stage, 1, 256)?;
1606        validate_optional_string_length(
1607            "removeFromVersionId",
1608            body["RemoveFromVersionId"].as_str(),
1609            32,
1610            64,
1611        )?;
1612        validate_optional_string_length(
1613            "moveToVersionId",
1614            body["MoveToVersionId"].as_str(),
1615            32,
1616            64,
1617        )?;
1618
1619        let move_to = body["MoveToVersionId"].as_str().map(|s| s.to_string());
1620        let remove_from = body["RemoveFromVersionId"].as_str().map(|s| s.to_string());
1621
1622        let mut accounts = self.state.write();
1623        let state = accounts.get_or_create(&req.account_id);
1624        let secret = self.find_secret_mut(state, &secret_id)?;
1625
1626        // Validate: if moving AWSCURRENT, must specify RemoveFromVersionId
1627        if version_stage == "AWSCURRENT" && move_to.is_some() && remove_from.is_none() {
1628            // Find the version that currently has AWSCURRENT
1629            let current_holder = secret
1630                .versions
1631                .iter()
1632                .find(|(_, v)| v.stages.contains(&"AWSCURRENT".to_string()))
1633                .map(|(id, _)| id.clone());
1634
1635            if let Some(current_vid) = current_holder {
1636                return Err(AwsServiceError::aws_error(
1637                    StatusCode::BAD_REQUEST,
1638                    "InvalidParameterException",
1639                    format!(
1640                        "The parameter RemoveFromVersionId can't be empty. Staging label AWSCURRENT is currently attached to version {current_vid}, so you must explicitly reference that version in RemoveFromVersionId."
1641                    ),
1642                ));
1643            }
1644        }
1645
1646        // Moving the AWSCURRENT label needs the single-AWSCURRENT invariant. A
1647        // caller-supplied RemoveFromVersionId that does not actually hold
1648        // AWSCURRENT must not leave two versions labelled AWSCURRENT: demote
1649        // whichever version really holds it today.
1650        if version_stage == "AWSCURRENT" {
1651            if let Some(ref move_vid) = move_to {
1652                let current_holder = secret
1653                    .versions
1654                    .iter()
1655                    .find(|(id, v)| {
1656                        id.as_str() != move_vid.as_str()
1657                            && v.stages.contains(&"AWSCURRENT".to_string())
1658                    })
1659                    .map(|(id, _)| id.clone());
1660                if let Some(holder) = current_holder {
1661                    demote_current_to_previous(&mut secret.versions, &holder);
1662                }
1663                if let Some(version) = secret.versions.get_mut(move_vid) {
1664                    version.stages.retain(|s| s != "AWSPREVIOUS");
1665                    if !version.stages.contains(&version_stage) {
1666                        version.stages.push(version_stage.clone());
1667                    }
1668                }
1669                secret.current_version_id = Some(move_vid.clone());
1670            } else if let Some(ref remove_vid) = remove_from {
1671                // Remove-only: demote the named version, keeping it the sole
1672                // AWSPREVIOUS.
1673                demote_current_to_previous(&mut secret.versions, remove_vid);
1674            }
1675        } else {
1676            // Generic (custom / AWSPENDING) stage move.
1677            if let Some(ref remove_vid) = remove_from {
1678                if let Some(version) = secret.versions.get_mut(remove_vid) {
1679                    version.stages.retain(|s| s != &version_stage);
1680                }
1681            }
1682            if let Some(ref move_vid) = move_to {
1683                if let Some(version) = secret.versions.get_mut(move_vid) {
1684                    if !version.stages.contains(&version_stage) {
1685                        version.stages.push(version_stage.clone());
1686                    }
1687                }
1688            }
1689        }
1690
1691        let response = json!({
1692            "ARN": secret.arn,
1693            "Name": secret.name,
1694        });
1695
1696        Ok(AwsResponse::ok_json(response))
1697    }
1698
1699    fn batch_get_secret_value(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1700        let body = req.json_body();
1701        validate_optional_string_length("nextToken", body["NextToken"].as_str(), 1, 4096)?;
1702        let secret_id_list = body["SecretIdList"].as_array();
1703        let filters = body["Filters"].as_array();
1704        let max_results = body.get("MaxResults").and_then(|v| v.as_i64());
1705
1706        // Validate: can't use both SecretIdList and Filters
1707        if secret_id_list.is_some() && filters.is_some() {
1708            return Err(AwsServiceError::aws_error(
1709                StatusCode::BAD_REQUEST,
1710                "InvalidParameterException",
1711                "Either 'SecretIdList' or 'Filters' must be provided, but not both.",
1712            ));
1713        }
1714
1715        // Validate: MaxResults requires Filters
1716        if max_results.is_some() && filters.is_none() {
1717            return Err(AwsServiceError::aws_error(
1718                StatusCode::BAD_REQUEST,
1719                "InvalidParameterException",
1720                "'Filters' not specified. 'Filters' must also be specified when 'MaxResults' is provided.",
1721            ));
1722        }
1723
1724        let accounts = self.state.read();
1725        let empty = SecretsManagerState::new(&req.account_id, &req.region);
1726        let state = accounts.get(&req.account_id).unwrap_or(&empty);
1727        let mut secret_values: Vec<Value> = Vec::new();
1728        let mut errors: Vec<Value> = Vec::new();
1729        let mut next_token: Option<String> = None;
1730
1731        if let Some(id_list) = secret_id_list {
1732            for id_val in id_list {
1733                let sid = id_val.as_str().unwrap_or("");
1734                match self.find_secret_ref(state, sid) {
1735                    Ok(secret) => {
1736                        if secret.deleted {
1737                            errors.push(json!({
1738                                "SecretId": sid,
1739                                "ErrorCode": "InvalidRequestException",
1740                                "Message": "Secret is currently marked deleted. Secret can be recovered with RestoreSecret. Secret is currently marked deleted.",
1741                            }));
1742                        } else if let Some(ref current_vid) = secret.current_version_id {
1743                            if let Some(version) = secret.versions.get(current_vid) {
1744                                let mut entry = json!({
1745                                    "ARN": secret.arn,
1746                                    "Name": secret.name,
1747                                    "VersionId": version.version_id,
1748                                    "VersionStages": version.stages,
1749                                    "CreatedDate": version.created_at.timestamp_millis() as f64 / 1000.0,
1750                                });
1751                                if let Some(ref s) = version.secret_string {
1752                                    // Decrypt the same way GetSecretValue does;
1753                                    // pushing the raw stored value leaked
1754                                    // ciphertext when a KMS hook + KmsKeyId were
1755                                    // configured (bug-audit 2026-06-20, 1.10).
1756                                    let plaintext = self
1757                                        .maybe_decrypt_secret_string(
1758                                            &req.account_id,
1759                                            &secret.arn,
1760                                            secret.kms_key_id.as_deref(),
1761                                            Some(s.as_str()),
1762                                        )
1763                                        .unwrap_or_else(|| s.clone());
1764                                    entry["SecretString"] = json!(plaintext);
1765                                }
1766                                if let Some(ref b) = version.secret_binary {
1767                                    entry["SecretBinary"] = json!(base64_encode(b));
1768                                }
1769                                secret_values.push(entry);
1770                            } else {
1771                                errors.push(json!({
1772                                    "SecretId": sid,
1773                                    "ErrorCode": "ResourceNotFoundException",
1774                                    "Message": "Secrets Manager can't find the specified secret.",
1775                                }));
1776                            }
1777                        } else {
1778                            errors.push(json!({
1779                                "SecretId": sid,
1780                                "ErrorCode": "ResourceNotFoundException",
1781                                "Message": "Secrets Manager can't find the specified secret.",
1782                            }));
1783                        }
1784                    }
1785                    Err(_) => {
1786                        errors.push(json!({
1787                            "SecretId": sid,
1788                            "ErrorCode": "ResourceNotFoundException",
1789                            "Message": "Secrets Manager can't find the specified secret.",
1790                        }));
1791                    }
1792                }
1793            }
1794        } else if let Some(filters) = filters {
1795            // Get secrets matching filters
1796            let matching: Vec<&Secret> = state
1797                .secrets
1798                .values()
1799                .filter(|s| {
1800                    if s.deleted {
1801                        return false;
1802                    }
1803                    for filter in filters {
1804                        let key = filter["Key"].as_str().unwrap_or("");
1805                        let values: Vec<&str> = filter["Values"]
1806                            .as_array()
1807                            .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
1808                            .unwrap_or_default();
1809                        let matches = match key {
1810                            "name" => filter_name(s, &values),
1811                            "description" => filter_description(s, &values),
1812                            "tag-key" => filter_tag_key(s, &values),
1813                            "tag-value" => filter_tag_value(s, &values),
1814                            "all" => filter_all(s, &values),
1815                            _ => true,
1816                        };
1817                        if !matches {
1818                            return false;
1819                        }
1820                    }
1821                    true
1822                })
1823                .collect();
1824
1825            let limit = max_results.unwrap_or(100) as usize;
1826            let mut no_value_found = false;
1827            let mut matching = matching;
1828            matching.sort_by(|a, b| a.name.cmp(&b.name));
1829
1830            // Resume from the incoming NextToken (an opaque base64 offset into
1831            // the sorted result set); skip consumed entries and emit a new token
1832            // when more remain. Previously the extra results were silently
1833            // dropped and no NextToken was ever returned.
1834            let start = body["NextToken"]
1835                .as_str()
1836                .and_then(|t| t.parse::<usize>().ok())
1837                .unwrap_or(0);
1838            let end = (start + limit).min(matching.len());
1839            if end < matching.len() {
1840                next_token = Some(end.to_string());
1841            }
1842
1843            for secret in matching.iter().skip(start).take(limit) {
1844                if let Some(ref current_vid) = secret.current_version_id {
1845                    if let Some(version) = secret.versions.get(current_vid) {
1846                        let mut entry = json!({
1847                            "ARN": secret.arn,
1848                            "Name": secret.name,
1849                            "VersionId": version.version_id,
1850                            "VersionStages": version.stages,
1851                            "CreatedDate": version.created_at.timestamp_millis() as f64 / 1000.0,
1852                        });
1853                        if let Some(ref s) = version.secret_string {
1854                            // Decrypt like GetSecretValue; the raw stored value
1855                            // is ciphertext under a configured KMS hook (1.10).
1856                            let plaintext = self
1857                                .maybe_decrypt_secret_string(
1858                                    &req.account_id,
1859                                    &secret.arn,
1860                                    secret.kms_key_id.as_deref(),
1861                                    Some(s.as_str()),
1862                                )
1863                                .unwrap_or_else(|| s.clone());
1864                            entry["SecretString"] = json!(plaintext);
1865                        }
1866                        if let Some(ref b) = version.secret_binary {
1867                            entry["SecretBinary"] = json!(base64_encode(b));
1868                        }
1869                        secret_values.push(entry);
1870                    } else {
1871                        no_value_found = true;
1872                    }
1873                } else {
1874                    no_value_found = true;
1875                }
1876            }
1877
1878            if no_value_found && secret_values.is_empty() {
1879                return Err(AwsServiceError::aws_error(
1880                    StatusCode::NOT_FOUND,
1881                    "ResourceNotFoundException",
1882                    "Secrets Manager can't find the specified secret.",
1883                ));
1884            }
1885        }
1886
1887        let mut response = json!({
1888            "SecretValues": secret_values,
1889            "Errors": errors,
1890        });
1891
1892        // Remove empty arrays
1893        if errors.is_empty() {
1894            response.as_object_mut().unwrap().remove("Errors");
1895        }
1896        if let Some(token) = next_token {
1897            response
1898                .as_object_mut()
1899                .unwrap()
1900                .insert("NextToken".to_string(), json!(token));
1901        }
1902
1903        Ok(AwsResponse::ok_json(response))
1904    }
1905
1906    fn get_resource_policy(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1907        let body = req.json_body();
1908        let secret_id = require_secret_id(&body)?;
1909
1910        let accounts = self.state.read();
1911        let empty = SecretsManagerState::new(&req.account_id, &req.region);
1912        let state = accounts.get(&req.account_id).unwrap_or(&empty);
1913        let secret = self.find_secret_ref(state, &secret_id)?;
1914
1915        // Real AWS omits ResourcePolicy when none is attached; terraform
1916        // provider and the SDK choke on an empty-string policy.
1917        let mut response = json!({
1918            "ARN": secret.arn,
1919            "Name": secret.name,
1920        });
1921        if let Some(ref policy) = secret.resource_policy {
1922            response["ResourcePolicy"] = json!(policy);
1923        }
1924
1925        Ok(AwsResponse::ok_json(response))
1926    }
1927
1928    fn validate_resource_policy(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1929        let body = req.json_body();
1930        validate_optional_string_length("secretId", body["SecretId"].as_str(), 1, 2048)?;
1931        validate_required("ResourcePolicy", &body["ResourcePolicy"])?;
1932        let policy_str = body["ResourcePolicy"].as_str().ok_or_else(|| {
1933            AwsServiceError::aws_error(
1934                StatusCode::BAD_REQUEST,
1935                "InvalidParameterException",
1936                "ResourcePolicy must be a string",
1937            )
1938        })?;
1939        validate_string_length("resourcePolicy", policy_str, 1, 20480)?;
1940
1941        // If SecretId is provided, verify the secret exists
1942        if let Some(secret_id) = body["SecretId"].as_str() {
1943            let accounts = self.state.read();
1944            let empty = SecretsManagerState::new(&req.account_id, &req.region);
1945            let state = accounts.get(&req.account_id).unwrap_or(&empty);
1946            self.find_secret_key(state, secret_id)?;
1947        }
1948
1949        let response = json!({
1950            "PolicyValidationPassed": true,
1951            "ValidationErrors": [],
1952        });
1953        Ok(AwsResponse::ok_json(response))
1954    }
1955
1956    fn put_resource_policy(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1957        let body = req.json_body();
1958        let secret_id = require_secret_id(&body)?;
1959        validate_required("ResourcePolicy", &body["ResourcePolicy"])?;
1960        validate_optional_string_length(
1961            "resourcePolicy",
1962            body["ResourcePolicy"].as_str(),
1963            1,
1964            20480,
1965        )?;
1966        let policy = body["ResourcePolicy"].as_str().map(|s| s.to_string());
1967
1968        let mut accounts = self.state.write();
1969        let state = accounts.get_or_create(&req.account_id);
1970        let secret = self.find_secret_mut(state, &secret_id)?;
1971        secret.resource_policy = policy;
1972
1973        let response = json!({
1974            "ARN": secret.arn,
1975            "Name": secret.name,
1976        });
1977
1978        Ok(AwsResponse::ok_json(response))
1979    }
1980
1981    fn delete_resource_policy(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1982        let body = req.json_body();
1983        let secret_id = require_secret_id(&body)?;
1984
1985        let mut accounts = self.state.write();
1986        let state = accounts.get_or_create(&req.account_id);
1987        let secret = self.find_secret_mut(state, &secret_id)?;
1988        secret.resource_policy = None;
1989
1990        let response = json!({
1991            "ARN": secret.arn,
1992            "Name": secret.name,
1993        });
1994
1995        Ok(AwsResponse::ok_json(response))
1996    }
1997
1998    fn replicate_secret_to_regions(
1999        &self,
2000        req: &AwsRequest,
2001    ) -> Result<AwsResponse, AwsServiceError> {
2002        let body = req.json_body();
2003        let secret_id = require_secret_id(&body)?;
2004        // AddReplicaRegions[].Region — the regions to replicate into.
2005        let add_regions: Vec<String> = body["AddReplicaRegions"]
2006            .as_array()
2007            .map(|arr| {
2008                arr.iter()
2009                    .filter_map(|r| r["Region"].as_str().map(String::from))
2010                    .collect()
2011            })
2012            .unwrap_or_default();
2013
2014        let mut accounts = self.state.write();
2015        let state = accounts.get_or_create(&req.account_id);
2016        let secret = self.find_secret_mut(state, &secret_id)?;
2017        for region in add_regions {
2018            if !secret.replica_regions.contains(&region) {
2019                secret.replica_regions.push(region);
2020            }
2021        }
2022        let response = json!({
2023            "ARN": secret.arn,
2024            "ReplicationStatus": replication_status_json(&secret.replica_regions),
2025        });
2026        Ok(AwsResponse::ok_json(response))
2027    }
2028
2029    fn remove_regions_from_replication(
2030        &self,
2031        req: &AwsRequest,
2032    ) -> Result<AwsResponse, AwsServiceError> {
2033        let body = req.json_body();
2034        let secret_id = require_secret_id(&body)?;
2035        let remove_regions: Vec<String> = body["RemoveReplicaRegions"]
2036            .as_array()
2037            .map(|arr| {
2038                arr.iter()
2039                    .filter_map(|r| r.as_str().map(String::from))
2040                    .collect()
2041            })
2042            .unwrap_or_default();
2043
2044        let mut accounts = self.state.write();
2045        let state = accounts.get_or_create(&req.account_id);
2046        let secret = self.find_secret_mut(state, &secret_id)?;
2047        secret
2048            .replica_regions
2049            .retain(|r| !remove_regions.contains(r));
2050        let response = json!({
2051            "ARN": secret.arn,
2052            "ReplicationStatus": replication_status_json(&secret.replica_regions),
2053        });
2054        Ok(AwsResponse::ok_json(response))
2055    }
2056
2057    fn stop_replication_to_replica(
2058        &self,
2059        req: &AwsRequest,
2060    ) -> Result<AwsResponse, AwsServiceError> {
2061        let body = req.json_body();
2062        let secret_id = require_secret_id(&body)?;
2063
2064        let accounts = self.state.read();
2065        let empty = SecretsManagerState::new(&req.account_id, &req.region);
2066        let state = accounts.get(&req.account_id).unwrap_or(&empty);
2067        let secret = self.find_secret_ref(state, &secret_id)?;
2068
2069        let response = json!({
2070            "ARN": secret.arn,
2071        });
2072        Ok(AwsResponse::ok_json(response))
2073    }
2074
2075    /// Find a secret by name, full ARN, or partial ARN (mutable).
2076    fn find_secret_mut<'a>(
2077        &self,
2078        state: &'a mut crate::state::SecretsManagerState,
2079        secret_id: &str,
2080    ) -> Result<&'a mut Secret, AwsServiceError> {
2081        let key = self.find_secret_key(state, secret_id)?;
2082        Ok(state.secrets.get_mut(&key).unwrap())
2083    }
2084
2085    fn find_secret_key(
2086        &self,
2087        state: &crate::state::SecretsManagerState,
2088        secret_id: &str,
2089    ) -> Result<String, AwsServiceError> {
2090        let key = if state.secrets.contains_key(secret_id) {
2091            Some(secret_id.to_string())
2092        } else if let Some(secret) = state.secrets.values().find(|s| s.arn == secret_id) {
2093            Some(secret.name.clone())
2094        } else if secret_id.starts_with("arn:aws:secretsmanager:") {
2095            state
2096                .secrets
2097                .values()
2098                .find(|s| s.arn.starts_with(secret_id))
2099                .map(|s| s.name.clone())
2100        } else {
2101            None
2102        };
2103
2104        match key {
2105            // A secret whose recovery window has elapsed is treated as gone.
2106            Some(key)
2107                if state
2108                    .secrets
2109                    .get(&key)
2110                    .map(|s| secret_recovery_window_elapsed(s, Utc::now()))
2111                    .unwrap_or(false) =>
2112            {
2113                Err(secret_not_found())
2114            }
2115            Some(key) => Ok(key),
2116            None => Err(secret_not_found()),
2117        }
2118    }
2119
2120    /// Find a secret by name, full ARN, or partial ARN (immutable).
2121    fn find_secret_ref<'a>(
2122        &self,
2123        state: &'a crate::state::SecretsManagerState,
2124        secret_id: &str,
2125    ) -> Result<&'a Secret, AwsServiceError> {
2126        let secret = state
2127            .secrets
2128            .get(secret_id)
2129            .or_else(|| state.secrets.values().find(|s| s.arn == secret_id))
2130            .or_else(|| {
2131                if secret_id.starts_with("arn:aws:secretsmanager:") {
2132                    state
2133                        .secrets
2134                        .values()
2135                        .find(|s| s.arn.starts_with(secret_id))
2136                } else {
2137                    None
2138                }
2139            });
2140
2141        match secret {
2142            // A secret whose recovery window has elapsed is treated as gone.
2143            Some(secret) if secret_recovery_window_elapsed(secret, Utc::now()) => {
2144                Err(secret_not_found())
2145            }
2146            Some(secret) => Ok(secret),
2147            None => Err(secret_not_found()),
2148        }
2149    }
2150}
2151
2152/// The standard "secret can't be found" error, shared by the resolution helpers.
2153fn secret_not_found() -> AwsServiceError {
2154    AwsServiceError::aws_error(
2155        StatusCode::NOT_FOUND,
2156        "ResourceNotFoundException",
2157        "Secrets Manager can't find the specified secret.",
2158    )
2159}
2160
2161/// Persist the current Secrets Manager state as a snapshot. Offloads the
2162/// serialization and blocking file write to the Tokio blocking pool. Noop when
2163/// `store` is `None` (memory mode). Shared by
2164/// `SecretsManagerService::save_snapshot` and
2165/// the CloudFormation provisioner's post-provision persist hook so both route
2166/// through the same serialize-and-write path.
2167pub async fn save_secretsmanager_snapshot(
2168    state: &SharedSecretsManagerState,
2169    store: Option<Arc<dyn SnapshotStore>>,
2170    lock: &AsyncMutex<()>,
2171) {
2172    let Some(store) = store else {
2173        return;
2174    };
2175    let _guard = lock.lock().await;
2176    let snapshot = SecretsManagerSnapshot {
2177        schema_version: SECRETSMANAGER_SNAPSHOT_SCHEMA_VERSION,
2178        state: None,
2179        accounts: Some(state.read().clone()),
2180    };
2181    let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
2182        let bytes = serde_json::to_vec(&snapshot)
2183            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
2184        store.save(&bytes)
2185    })
2186    .await;
2187    match join {
2188        Ok(Ok(())) => {}
2189        Ok(Err(err)) => tracing::error!(%err, "failed to write secretsmanager snapshot"),
2190        Err(err) => tracing::error!(%err, "secretsmanager snapshot task panicked"),
2191    }
2192}
2193
2194/// Parsed + validated inputs for `CreateSecret`.
2195struct CreateSecretInput {
2196    name: String,
2197    client_request_token: Option<String>,
2198    description: Option<String>,
2199    kms_key_id: Option<String>,
2200    secret_string: Option<String>,
2201    secret_binary: Option<Vec<u8>>,
2202    tags: Vec<(String, String)>,
2203    /// Regions requested via CreateSecret's `AddReplicaRegions`. Persisted
2204    /// as replica regions so DescribeSecret and the CreateSecret response
2205    /// both report `ReplicationStatus`.
2206    add_replica_regions: Vec<String>,
2207}
2208
2209impl CreateSecretInput {
2210    fn from_body(body: &Value) -> Result<Self, AwsServiceError> {
2211        validate_required("Name", &body["Name"])?;
2212        let name = body["Name"]
2213            .as_str()
2214            .ok_or_else(|| {
2215                AwsServiceError::aws_error(
2216                    StatusCode::BAD_REQUEST,
2217                    "InvalidParameterException",
2218                    "Name is required",
2219                )
2220            })?
2221            .to_string();
2222        validate_string_length("name", &name, 1, 512)?;
2223        validate_secret_name_charset(&name)?;
2224        validate_optional_string_length(
2225            "clientRequestToken",
2226            body["ClientRequestToken"].as_str(),
2227            32,
2228            64,
2229        )?;
2230        validate_optional_string_length("description", body["Description"].as_str(), 0, 2048)?;
2231        validate_optional_string_length("kmsKeyId", body["KmsKeyId"].as_str(), 0, 2048)?;
2232        validate_optional_string_length("secretString", body["SecretString"].as_str(), 1, 65536)?;
2233
2234        Ok(Self {
2235            name,
2236            client_request_token: body["ClientRequestToken"].as_str().map(|s| s.to_string()),
2237            description: body["Description"].as_str().map(|s| s.to_string()),
2238            kms_key_id: body["KmsKeyId"].as_str().map(|s| s.to_string()),
2239            secret_string: body["SecretString"].as_str().map(|s| s.to_string()),
2240            secret_binary: parse_secret_binary(body)?,
2241            tags: parse_tags(&body["Tags"]),
2242            add_replica_regions: body["AddReplicaRegions"]
2243                .as_array()
2244                .map(|arr| {
2245                    arr.iter()
2246                        .filter_map(|r| r["Region"].as_str().map(String::from))
2247                        .collect()
2248                })
2249                .unwrap_or_default(),
2250        })
2251    }
2252}
2253
2254#[async_trait]
2255impl AwsService for SecretsManagerService {
2256    fn service_name(&self) -> &str {
2257        "secretsmanager"
2258    }
2259
2260    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2261        let mutates = is_mutating_action(req.action.as_str());
2262        let result = match req.action.as_str() {
2263            "CreateSecret" => self.create_secret(&req),
2264            "GetSecretValue" => self.get_secret_value(&req),
2265            "PutSecretValue" => self.put_secret_value(&req),
2266            "UpdateSecret" => self.update_secret(&req),
2267            "DeleteSecret" => self.delete_secret(&req),
2268            "RestoreSecret" => self.restore_secret(&req),
2269            "DescribeSecret" => self.describe_secret(&req),
2270            "ListSecrets" => self.list_secrets(&req),
2271            "TagResource" => self.tag_resource(&req),
2272            "UntagResource" => self.untag_resource(&req),
2273            "ListSecretVersionIds" => self.list_secret_version_ids(&req),
2274            "GetRandomPassword" => self.get_random_password(&req),
2275            "RotateSecret" => {
2276                let (response, invocation) = self.rotate_secret(&req)?;
2277                if let Some(inv) = invocation {
2278                    if let Some(cleanup) = inv.cleanup_pending.as_ref() {
2279                        // Test-only rotation (RotateImmediately=false): run the
2280                        // testSecret invocation and remove the temporary
2281                        // AWSPENDING version SYNCHRONOUSLY, inside the handler's
2282                        // critical section, so the mutating-action snapshot
2283                        // taken at the end of `handle` never captures (and can't
2284                        // restore) the temporary version.
2285                        if let Some(ref bus) = self.delivery_bus {
2286                            run_rotation_steps(bus, &inv).await;
2287                        }
2288                        remove_rotation_test_pending(&self.state, cleanup);
2289                    } else {
2290                        // Full rotation: AWS invokes the rotation Lambda
2291                        // asynchronously for each step. The Lambda's own
2292                        // PutSecretValue calls persist their own snapshots.
2293                        let bus = self.delivery_bus.clone();
2294                        tokio::spawn(async move {
2295                            if let Some(bus) = bus {
2296                                run_rotation_steps(&bus, &inv).await;
2297                            }
2298                        });
2299                    }
2300                }
2301                Ok(response)
2302            }
2303            "CancelRotateSecret" => self.cancel_rotate_secret(&req),
2304            "UpdateSecretVersionStage" => self.update_secret_version_stage(&req),
2305            "BatchGetSecretValue" => self.batch_get_secret_value(&req),
2306            "GetResourcePolicy" => self.get_resource_policy(&req),
2307            "PutResourcePolicy" => self.put_resource_policy(&req),
2308            "DeleteResourcePolicy" => self.delete_resource_policy(&req),
2309            "ValidateResourcePolicy" => self.validate_resource_policy(&req),
2310            "ReplicateSecretToRegions" => self.replicate_secret_to_regions(&req),
2311            "RemoveRegionsFromReplication" => self.remove_regions_from_replication(&req),
2312            "StopReplicationToReplica" => self.stop_replication_to_replica(&req),
2313            _ => Err(AwsServiceError::action_not_implemented(
2314                "secretsmanager",
2315                &req.action,
2316            )),
2317        };
2318        if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
2319            self.save_snapshot().await;
2320        }
2321        result.map_err(remap_validation_error)
2322    }
2323
2324    fn supported_actions(&self) -> &[&str] {
2325        &[
2326            "CreateSecret",
2327            "GetSecretValue",
2328            "PutSecretValue",
2329            "UpdateSecret",
2330            "DeleteSecret",
2331            "RestoreSecret",
2332            "DescribeSecret",
2333            "ListSecrets",
2334            "TagResource",
2335            "UntagResource",
2336            "ListSecretVersionIds",
2337            "GetRandomPassword",
2338            "RotateSecret",
2339            "CancelRotateSecret",
2340            "UpdateSecretVersionStage",
2341            "BatchGetSecretValue",
2342            "GetResourcePolicy",
2343            "PutResourcePolicy",
2344            "DeleteResourcePolicy",
2345            "ValidateResourcePolicy",
2346            "ReplicateSecretToRegions",
2347            "RemoveRegionsFromReplication",
2348            "StopReplicationToReplica",
2349        ]
2350    }
2351}
2352
2353#[path = "service_helpers.rs"]
2354mod service_helpers;
2355pub(crate) use service_helpers::*;
2356
2357/// The shared `fakecloud_core::validation` helpers raise
2358/// `ValidationException`, but the Secrets Manager Smithy model does not
2359/// declare `ValidationException` on any operation — its declared input
2360/// error is `InvalidParameterException`. Translate at the dispatcher
2361/// boundary so the wire-level error code matches real AWS without
2362/// duplicating every validator.
2363/// Build the `ReplicationStatus` array from a secret's replica regions.
2364/// Each replica reports `InSync`, matching a healthy replication.
2365fn replication_status_json(regions: &[String]) -> Value {
2366    Value::Array(
2367        regions
2368            .iter()
2369            .map(|r| {
2370                json!({
2371                    "Region": r,
2372                    "Status": "InSync",
2373                    "StatusMessage": "Replication succeeded",
2374                })
2375            })
2376            .collect(),
2377    )
2378}
2379
2380fn remap_validation_error(err: AwsServiceError) -> AwsServiceError {
2381    match err {
2382        AwsServiceError::AwsError {
2383            status,
2384            code,
2385            message,
2386            extra_fields,
2387            headers,
2388        } if code == "ValidationException" => AwsServiceError::AwsError {
2389            status,
2390            code: "InvalidParameterException".to_string(),
2391            message,
2392            extra_fields,
2393            headers,
2394        },
2395        other => other,
2396    }
2397}
2398
2399/// Extract the owning account-id from an `arn:aws:secretsmanager:...:ACCOUNT:secret:...`
2400/// secret id. Returns `caller_account` when the input is a bare name
2401/// or a same-account ARN.
2402fn secret_owner_account(secret_id: &str, caller_account: &str) -> String {
2403    if !secret_id.starts_with("arn:aws:secretsmanager:") {
2404        return caller_account.to_string();
2405    }
2406    let parts: Vec<&str> = secret_id.splitn(7, ':').collect();
2407    if parts.len() < 5 {
2408        return caller_account.to_string();
2409    }
2410    let account = parts[4];
2411    if account.is_empty() {
2412        caller_account.to_string()
2413    } else {
2414        account.to_string()
2415    }
2416}
2417
2418/// Evaluate a Secrets Manager resource policy against a cross-account
2419/// caller. Empty policy implicitly denies (real AWS behaviour). Uses
2420/// the shared IAM evaluator so the same policy semantics apply
2421/// service-wide.
2422fn resource_policy_allows(policy_doc: &str, caller_account: &str, secret_arn: &str) -> bool {
2423    if policy_doc.is_empty() {
2424        return false;
2425    }
2426    use fakecloud_core::auth::{Principal, PrincipalType};
2427    use fakecloud_iam::evaluator::{evaluate, EvalRequest, PolicyDocument};
2428    let doc = PolicyDocument::parse(policy_doc);
2429    let principal_arn = Arn::global("iam", caller_account, "root").to_string();
2430    let principal = Principal {
2431        arn: principal_arn.clone(),
2432        user_id: principal_arn.clone(),
2433        account_id: caller_account.to_string(),
2434        principal_type: PrincipalType::User,
2435        source_identity: None,
2436        tags: None,
2437    };
2438    let req = EvalRequest {
2439        principal: &principal,
2440        action: "secretsmanager:GetSecretValue".to_string(),
2441        resource: secret_arn.to_string(),
2442        context: Default::default(),
2443    };
2444    matches!(
2445        evaluate(&[doc], &req),
2446        fakecloud_iam::evaluator::Decision::Allow
2447    )
2448}
2449
2450#[cfg(test)]
2451#[path = "service_tests.rs"]
2452mod tests;