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
1047        let start_idx = if let Some(token) = next_token {
1048            secrets.iter().position(|s| s.name == token).unwrap_or(0)
1049        } else {
1050            0
1051        };
1052
1053        let page: Vec<Value> = secrets
1054            .iter()
1055            .skip(start_idx)
1056            .take(max_results)
1057            .map(|s| {
1058                // AWS always echoes `Description` and `SecretVersionsToStages`
1059                // on every SecretListEntry. The documented `@examples` for
1060                // ListSecrets relies on the fields being present even when
1061                // empty, and SDK consumers index into them unconditionally.
1062                let mut version_ids_to_stages: serde_json::Map<String, Value> =
1063                    serde_json::Map::new();
1064                for (vid, version) in &s.versions {
1065                    version_ids_to_stages.insert(vid.clone(), json!(version.stages));
1066                }
1067                let mut entry = json!({
1068                    "ARN": s.arn,
1069                    "Name": s.name,
1070                    "CreatedDate": s.created_at.timestamp_millis() as f64 / 1000.0,
1071                    "LastChangedDate": s.last_changed_at.timestamp_millis() as f64 / 1000.0,
1072                    "Description": s.description.clone().unwrap_or_default(),
1073                    "SecretVersionsToStages": Value::Object(version_ids_to_stages),
1074                });
1075
1076                if s.tags_ever_set || !s.tags.is_empty() {
1077                    entry["Tags"] = json!(tags_to_json(&s.tags));
1078                }
1079
1080                if let Some(ref kms) = s.kms_key_id {
1081                    entry["KmsKeyId"] = json!(kms);
1082                }
1083                if s.deleted {
1084                    entry["DeletedDate"] = json!(s
1085                        .deletion_date
1086                        .map(|d| d.timestamp_millis() as f64 / 1000.0));
1087                }
1088                entry
1089            })
1090            .collect();
1091
1092        let has_more = start_idx + max_results < secrets.len();
1093        let mut response = json!({
1094            "SecretList": page,
1095        });
1096        if has_more {
1097            if let Some(next) = secrets.get(start_idx + max_results) {
1098                response["NextToken"] = json!(next.name);
1099            }
1100        }
1101
1102        Ok(AwsResponse::ok_json(response))
1103    }
1104
1105    fn tag_resource(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1106        let body = req.json_body();
1107        let secret_id = require_secret_id(&body)?;
1108
1109        let new_tags = parse_tags(&body["Tags"]);
1110
1111        let mut accounts = self.state.write();
1112        let state = accounts.get_or_create(&req.account_id);
1113        let secret = self.find_secret_mut(state, &secret_id)?;
1114
1115        if !new_tags.is_empty() {
1116            secret.tags_ever_set = true;
1117        }
1118        for (k, v) in new_tags {
1119            // Update existing tag or add new one
1120            if let Some(existing) = secret.tags.iter_mut().find(|(ek, _)| *ek == k) {
1121                existing.1 = v;
1122            } else {
1123                secret.tags.push((k, v));
1124            }
1125        }
1126
1127        Ok(AwsResponse::json(StatusCode::OK, "{}"))
1128    }
1129
1130    fn untag_resource(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1131        let body = req.json_body();
1132        let secret_id = require_secret_id(&body)?;
1133
1134        let tag_keys: Vec<String> = body["TagKeys"]
1135            .as_array()
1136            .map(|arr| {
1137                arr.iter()
1138                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
1139                    .collect()
1140            })
1141            .unwrap_or_default();
1142
1143        let mut accounts = self.state.write();
1144        let state = accounts.get_or_create(&req.account_id);
1145        let secret = self.find_secret_mut(state, &secret_id)?;
1146
1147        secret.tags.retain(|(k, _)| !tag_keys.contains(k));
1148
1149        Ok(AwsResponse::json(StatusCode::OK, "{}"))
1150    }
1151
1152    fn list_secret_version_ids(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1153        let body = req.json_body();
1154        let secret_id = require_secret_id(&body)?;
1155        validate_optional_string_length("nextToken", body["NextToken"].as_str(), 1, 4096)?;
1156        validate_optional_range_i64("maxResults", body["MaxResults"].as_i64(), 1, 100)?;
1157        let max_results = body["MaxResults"].as_i64().unwrap_or(100) as usize;
1158        let next_token = body["NextToken"].as_str();
1159
1160        let accounts = self.state.read();
1161        let empty = SecretsManagerState::new(&req.account_id, &req.region);
1162        let state = accounts.get(&req.account_id).unwrap_or(&empty);
1163        let secret = self.find_secret_ref(state, &secret_id)?;
1164
1165        // Stable order so the NextToken (a version id) resumes deterministically:
1166        // newest first by CreatedDate, version id as a tiebreaker.
1167        let mut versions: Vec<&_> = secret.versions.values().collect();
1168        versions.sort_by(|a, b| {
1169            b.created_at
1170                .cmp(&a.created_at)
1171                .then_with(|| a.version_id.cmp(&b.version_id))
1172        });
1173
1174        let start_idx = if let Some(token) = next_token {
1175            versions
1176                .iter()
1177                .position(|v| v.version_id == token)
1178                .unwrap_or(versions.len())
1179        } else {
1180            0
1181        };
1182
1183        let page: Vec<Value> = versions
1184            .iter()
1185            .skip(start_idx)
1186            .take(max_results)
1187            .map(|v| {
1188                json!({
1189                    "VersionId": v.version_id,
1190                    "VersionStages": v.stages,
1191                    "CreatedDate": v.created_at.timestamp_millis() as f64 / 1000.0,
1192                })
1193            })
1194            .collect();
1195
1196        let mut response = json!({
1197            "ARN": secret.arn,
1198            "Name": secret.name,
1199            "Versions": page,
1200        });
1201        if start_idx + max_results < versions.len() {
1202            if let Some(next) = versions.get(start_idx + max_results) {
1203                response["NextToken"] = json!(next.version_id);
1204            }
1205        }
1206
1207        Ok(AwsResponse::ok_json(response))
1208    }
1209
1210    fn get_random_password(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1211        let body = req.json_body();
1212        let length = body["PasswordLength"].as_i64().unwrap_or(32) as usize;
1213
1214        if length < 4 {
1215            return Err(AwsServiceError::aws_error(
1216                StatusCode::BAD_REQUEST,
1217                "InvalidParameterException",
1218                "Invalid value for parameter PasswordLength; must be 4..=4096.",
1219            ));
1220        }
1221        if length > 4096 {
1222            return Err(AwsServiceError::aws_error(
1223                StatusCode::BAD_REQUEST,
1224                "InvalidParameterException",
1225                "Invalid value for parameter PasswordLength; must be 4..=4096.",
1226            ));
1227        }
1228
1229        let exclude_lowercase = body["ExcludeLowercase"].as_bool().unwrap_or(false);
1230        let exclude_uppercase = body["ExcludeUppercase"].as_bool().unwrap_or(false);
1231        let exclude_numbers = body["ExcludeNumbers"].as_bool().unwrap_or(false);
1232        let exclude_punctuation = body["ExcludePunctuation"].as_bool().unwrap_or(false);
1233        let include_space = body["IncludeSpace"].as_bool().unwrap_or(false);
1234        let require_each = body["RequireEachIncludedType"].as_bool().unwrap_or(true);
1235        validate_optional_string_length(
1236            "excludeCharacters",
1237            body["ExcludeCharacters"].as_str(),
1238            0,
1239            4096,
1240        )?;
1241        let exclude_chars = body["ExcludeCharacters"].as_str().unwrap_or("").to_string();
1242
1243        let lowercase = "abcdefghijklmnopqrstuvwxyz";
1244        let uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
1245        let digits = "0123456789";
1246        let punctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
1247
1248        let mut char_pool = String::new();
1249        let mut required_chars: Vec<String> = Vec::new();
1250
1251        if !exclude_lowercase {
1252            let filtered: String = lowercase
1253                .chars()
1254                .filter(|c| !exclude_chars.contains(*c))
1255                .collect();
1256            if !filtered.is_empty() {
1257                required_chars.push(filtered.clone());
1258                char_pool.push_str(&filtered);
1259            }
1260        }
1261        if !exclude_uppercase {
1262            let filtered: String = uppercase
1263                .chars()
1264                .filter(|c| !exclude_chars.contains(*c))
1265                .collect();
1266            if !filtered.is_empty() {
1267                required_chars.push(filtered.clone());
1268                char_pool.push_str(&filtered);
1269            }
1270        }
1271        if !exclude_numbers {
1272            let filtered: String = digits
1273                .chars()
1274                .filter(|c| !exclude_chars.contains(*c))
1275                .collect();
1276            if !filtered.is_empty() {
1277                required_chars.push(filtered.clone());
1278                char_pool.push_str(&filtered);
1279            }
1280        }
1281        if !exclude_punctuation {
1282            let filtered: String = punctuation
1283                .chars()
1284                .filter(|c| !exclude_chars.contains(*c))
1285                .collect();
1286            if !filtered.is_empty() {
1287                required_chars.push(filtered.clone());
1288                char_pool.push_str(&filtered);
1289            }
1290        }
1291        if include_space && !exclude_chars.contains(' ') {
1292            char_pool.push(' ');
1293        }
1294
1295        if char_pool.is_empty() {
1296            return Err(AwsServiceError::aws_error(
1297                StatusCode::BAD_REQUEST,
1298                "InvalidParameterException",
1299                "InvalidParameterException",
1300            ));
1301        }
1302
1303        let pool_bytes: Vec<char> = char_pool.chars().collect();
1304        let mut password = String::with_capacity(length);
1305
1306        // Use simple random generation
1307        if require_each {
1308            // Each required class (plus space, when included) consumes one
1309            // guaranteed slot. If the requested length can't hold one of each,
1310            // AWS rejects the request rather than silently dropping a class.
1311            let mut required_count = required_chars.len();
1312            if include_space && !exclude_chars.contains(' ') {
1313                required_count += 1;
1314            }
1315            if length < required_count {
1316                return Err(AwsServiceError::aws_error(
1317                    StatusCode::BAD_REQUEST,
1318                    "InvalidParameterException",
1319                    "PasswordLength is too short to include one of each required character type.",
1320                ));
1321            }
1322            // First, ensure at least one character from each required category
1323            for category in &required_chars {
1324                let chars: Vec<char> = category.chars().collect();
1325                let idx = simple_random() % chars.len();
1326                password.push(chars[idx]);
1327            }
1328            if include_space && !exclude_chars.contains(' ') {
1329                password.push(' ');
1330            }
1331        }
1332
1333        // Fill the rest randomly
1334        while password.len() < length {
1335            let idx = simple_random() % pool_bytes.len();
1336            password.push(pool_bytes[idx]);
1337        }
1338
1339        // Shuffle the password (Fisher-Yates)
1340        let mut chars: Vec<char> = password.chars().collect();
1341        for i in (1..chars.len()).rev() {
1342            let j = simple_random() % (i + 1);
1343            chars.swap(i, j);
1344        }
1345        let password: String = chars.into_iter().take(length).collect();
1346
1347        let response = json!({
1348            "RandomPassword": password,
1349        });
1350
1351        Ok(AwsResponse::ok_json(response))
1352    }
1353
1354    fn rotate_secret(
1355        &self,
1356        req: &AwsRequest,
1357    ) -> Result<(AwsResponse, Option<RotationInvocation>), AwsServiceError> {
1358        let body = req.json_body();
1359        let secret_id = require_secret_id(&body)?;
1360
1361        // Validate ClientRequestToken
1362        if let Some(token) = body["ClientRequestToken"].as_str() {
1363            if token.len() < 32 || token.len() > 64 {
1364                return Err(AwsServiceError::aws_error(
1365                    StatusCode::BAD_REQUEST,
1366                    "InvalidParameterException",
1367                    "ClientRequestToken must be 32-64 characters long.",
1368                ));
1369            }
1370        }
1371
1372        // Validate RotationLambdaARN
1373        if let Some(arn) = body["RotationLambdaARN"].as_str() {
1374            if arn.len() > 2048 {
1375                return Err(AwsServiceError::aws_error(
1376                    StatusCode::BAD_REQUEST,
1377                    "InvalidParameterException",
1378                    "RotationLambdaARN length must be less than or equal to 2048.",
1379                ));
1380            }
1381        }
1382
1383        // Validate RotationRules
1384        if let Some(rules) = body["RotationRules"].as_object() {
1385            if let Some(days) = rules.get("AutomaticallyAfterDays").and_then(|v| v.as_i64()) {
1386                if !(1..=1000).contains(&days) {
1387                    return Err(AwsServiceError::aws_error(
1388                        StatusCode::BAD_REQUEST,
1389                        "InvalidParameterException",
1390                        "RotationRules.AutomaticallyAfterDays must be within 1-1000.",
1391                    ));
1392                }
1393            }
1394        }
1395
1396        let mut accounts = self.state.write();
1397        let state = accounts.get_or_create(&req.account_id);
1398        let secret = self.find_secret_mut(state, &secret_id)?;
1399
1400        if secret.deleted {
1401            return Err(AwsServiceError::aws_error(
1402                StatusCode::BAD_REQUEST,
1403                "InvalidRequestException",
1404                "You can't perform this operation on the secret because it was marked for deletion.",
1405            ));
1406        }
1407
1408        // Set rotation config
1409        if let Some(lambda_arn) = body["RotationLambdaARN"].as_str() {
1410            secret.rotation_lambda_arn = Some(lambda_arn.to_string());
1411        }
1412
1413        if let Some(rules) = body["RotationRules"].as_object() {
1414            let days = rules.get("AutomaticallyAfterDays").and_then(|v| v.as_i64());
1415            secret.rotation_rules = Some(RotationRules {
1416                automatically_after_days: days,
1417                duration: rules
1418                    .get("Duration")
1419                    .and_then(|v| v.as_str())
1420                    .map(String::from),
1421                schedule_expression: rules
1422                    .get("ScheduleExpression")
1423                    .and_then(|v| v.as_str())
1424                    .map(String::from),
1425            });
1426        }
1427
1428        secret.rotation_enabled = Some(true);
1429        let now = Utc::now();
1430        secret.last_changed_at = now;
1431
1432        // RotateImmediately defaults to true. When false, Secrets Manager
1433        // saves the rotation configuration and schedules the next rotation
1434        // for the upcoming window WITHOUT rotating the value now (and
1435        // without invoking the rotation Lambda). LastRotatedDate is only
1436        // set when a rotation actually happens.
1437        let rotate_immediately = body["RotateImmediately"].as_bool().unwrap_or(true);
1438
1439        let version_id = body["ClientRequestToken"]
1440            .as_str()
1441            .map(|s| s.to_string())
1442            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1443
1444        let has_lambda =
1445            body["RotationLambdaARN"].as_str().is_some() || secret.rotation_lambda_arn.is_some();
1446        let lambda_arn = secret.rotation_lambda_arn.clone();
1447
1448        // Rotation behavior:
1449        // - RotateImmediately (default): a full rotation. With a Lambda, the
1450        //   Lambda runs all four steps; without one, the value is rotated
1451        //   in-place (new AWSCURRENT, old -> AWSPREVIOUS).
1452        // - RotateImmediately=false: the value is NOT rotated now. But when a
1453        //   rotation Lambda is configured, AWS still validates the rotation
1454        //   configuration by running ONLY the testSecret step (matching real
1455        //   Secrets Manager). LastRotatedDate is only set on an actual rotation.
1456        let mut invocation = None;
1457        if rotate_immediately {
1458            if let Some(current_vid) = secret.current_version_id.clone() {
1459                let current_value = secret.versions.get(&current_vid).cloned();
1460
1461                if let Some(cv) = current_value {
1462                    if has_lambda {
1463                        // With Lambda: do NOT pre-create the AWSPENDING version. The
1464                        // rotation Lambda is responsible for putting the new value via
1465                        // PutSecretValue with VersionStages=[AWSPENDING] during the
1466                        // createSecret step (matching real AWS Secrets Manager behavior).
1467
1468                        // Schedule Lambda invocation (full rotation steps).
1469                        if let Some(ref arn) = lambda_arn {
1470                            invocation = Some(RotationInvocation {
1471                                lambda_arn: arn.clone(),
1472                                secret_id: secret.arn.clone(),
1473                                client_request_token: version_id.clone(),
1474                                steps: vec![
1475                                    "createSecret",
1476                                    "setSecret",
1477                                    "testSecret",
1478                                    "finishSecret",
1479                                ],
1480                                cleanup_pending: None,
1481                            });
1482                        }
1483                    } else {
1484                        // Without Lambda: simple rotation - new version becomes AWSCURRENT.
1485                        // Move old version to AWSPREVIOUS (kept unique across versions).
1486                        demote_current_to_previous(&mut secret.versions, &current_vid);
1487                        let version = SecretVersion {
1488                            version_id: version_id.clone(),
1489                            secret_string: cv.secret_string.clone(),
1490                            secret_binary: cv.secret_binary.clone(),
1491                            stages: vec!["AWSCURRENT".to_string()],
1492                            created_at: now,
1493                        };
1494                        secret.versions.insert(version_id.clone(), version);
1495                        secret.current_version_id = Some(version_id.clone());
1496                        // The value has now actually rotated (synchronously), so
1497                        // this is the point where LastRotatedDate is stamped. The
1498                        // Lambda path does not claim completion here because the
1499                        // rotation runs asynchronously and may not succeed.
1500                        secret.last_rotated_at = Some(now);
1501                    }
1502                }
1503            }
1504        } else if has_lambda {
1505            // RotateImmediately=false with a Lambda: AWS validates the rotation
1506            // configuration by running only the testSecret step. The current
1507            // value is NOT rotated and LastRotatedDate is left untouched, but
1508            // the Lambda's testSecret step needs an AWSPENDING version to read.
1509            // Mirror AWS by staging a temporary AWSPENDING copy of the current
1510            // value (keyed by the rotation token) and removing it once the test
1511            // completes.
1512            if let (Some(arn), Some(current_vid)) =
1513                (lambda_arn.as_ref(), secret.current_version_id.clone())
1514            {
1515                if let Some(cv) = secret.versions.get(&current_vid).cloned() {
1516                    secret.versions.insert(
1517                        version_id.clone(),
1518                        SecretVersion {
1519                            version_id: version_id.clone(),
1520                            secret_string: cv.secret_string.clone(),
1521                            secret_binary: cv.secret_binary.clone(),
1522                            stages: vec!["AWSPENDING".to_string()],
1523                            created_at: now,
1524                        },
1525                    );
1526                    invocation = Some(RotationInvocation {
1527                        lambda_arn: arn.clone(),
1528                        secret_id: secret.arn.clone(),
1529                        client_request_token: version_id.clone(),
1530                        steps: vec!["testSecret"],
1531                        cleanup_pending: Some(PendingCleanup {
1532                            account_id: req.account_id.clone(),
1533                            secret_name: secret.name.clone(),
1534                            version_id: version_id.clone(),
1535                        }),
1536                    });
1537                }
1538            }
1539        }
1540
1541        let response = json!({
1542            "ARN": secret.arn,
1543            "Name": secret.name,
1544            "VersionId": version_id,
1545        });
1546
1547        Ok((AwsResponse::ok_json(response), invocation))
1548    }
1549
1550    fn cancel_rotate_secret(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1551        let body = req.json_body();
1552        let secret_id = require_secret_id(&body)?;
1553
1554        let mut accounts = self.state.write();
1555        let state = accounts.get_or_create(&req.account_id);
1556        let secret = self.find_secret_mut(state, &secret_id)?;
1557
1558        if secret.deleted {
1559            return Err(AwsServiceError::aws_error(
1560                StatusCode::BAD_REQUEST,
1561                "InvalidRequestException",
1562                "You can't perform this operation on the secret because it was marked for deletion.",
1563            ));
1564        }
1565
1566        if secret.rotation_enabled != Some(true) {
1567            return Err(AwsServiceError::aws_error(
1568                StatusCode::BAD_REQUEST,
1569                "InvalidRequestException",
1570                "You can't cancel rotation for a secret that does not have rotation enabled.",
1571            ));
1572        }
1573
1574        secret.rotation_enabled = Some(false);
1575
1576        let response = json!({
1577            "ARN": secret.arn,
1578            "Name": secret.name,
1579        });
1580
1581        Ok(AwsResponse::ok_json(response))
1582    }
1583
1584    fn update_secret_version_stage(
1585        &self,
1586        req: &AwsRequest,
1587    ) -> Result<AwsResponse, AwsServiceError> {
1588        let body = req.json_body();
1589        let secret_id = require_secret_id(&body)?;
1590        let version_stage = body["VersionStage"]
1591            .as_str()
1592            .ok_or_else(|| {
1593                AwsServiceError::aws_error(
1594                    StatusCode::BAD_REQUEST,
1595                    "InvalidParameterException",
1596                    "VersionStage is required",
1597                )
1598            })?
1599            .to_string();
1600        validate_string_length("versionStage", &version_stage, 1, 256)?;
1601        validate_optional_string_length(
1602            "removeFromVersionId",
1603            body["RemoveFromVersionId"].as_str(),
1604            32,
1605            64,
1606        )?;
1607        validate_optional_string_length(
1608            "moveToVersionId",
1609            body["MoveToVersionId"].as_str(),
1610            32,
1611            64,
1612        )?;
1613
1614        let move_to = body["MoveToVersionId"].as_str().map(|s| s.to_string());
1615        let remove_from = body["RemoveFromVersionId"].as_str().map(|s| s.to_string());
1616
1617        let mut accounts = self.state.write();
1618        let state = accounts.get_or_create(&req.account_id);
1619        let secret = self.find_secret_mut(state, &secret_id)?;
1620
1621        // Validate: if moving AWSCURRENT, must specify RemoveFromVersionId
1622        if version_stage == "AWSCURRENT" && move_to.is_some() && remove_from.is_none() {
1623            // Find the version that currently has AWSCURRENT
1624            let current_holder = secret
1625                .versions
1626                .iter()
1627                .find(|(_, v)| v.stages.contains(&"AWSCURRENT".to_string()))
1628                .map(|(id, _)| id.clone());
1629
1630            if let Some(current_vid) = current_holder {
1631                return Err(AwsServiceError::aws_error(
1632                    StatusCode::BAD_REQUEST,
1633                    "InvalidParameterException",
1634                    format!(
1635                        "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."
1636                    ),
1637                ));
1638            }
1639        }
1640
1641        // Moving the AWSCURRENT label needs the single-AWSCURRENT invariant. A
1642        // caller-supplied RemoveFromVersionId that does not actually hold
1643        // AWSCURRENT must not leave two versions labelled AWSCURRENT: demote
1644        // whichever version really holds it today.
1645        if version_stage == "AWSCURRENT" {
1646            if let Some(ref move_vid) = move_to {
1647                let current_holder = secret
1648                    .versions
1649                    .iter()
1650                    .find(|(id, v)| {
1651                        id.as_str() != move_vid.as_str()
1652                            && v.stages.contains(&"AWSCURRENT".to_string())
1653                    })
1654                    .map(|(id, _)| id.clone());
1655                if let Some(holder) = current_holder {
1656                    demote_current_to_previous(&mut secret.versions, &holder);
1657                }
1658                if let Some(version) = secret.versions.get_mut(move_vid) {
1659                    version.stages.retain(|s| s != "AWSPREVIOUS");
1660                    if !version.stages.contains(&version_stage) {
1661                        version.stages.push(version_stage.clone());
1662                    }
1663                }
1664                secret.current_version_id = Some(move_vid.clone());
1665            } else if let Some(ref remove_vid) = remove_from {
1666                // Remove-only: demote the named version, keeping it the sole
1667                // AWSPREVIOUS.
1668                demote_current_to_previous(&mut secret.versions, remove_vid);
1669            }
1670        } else {
1671            // Generic (custom / AWSPENDING) stage move.
1672            if let Some(ref remove_vid) = remove_from {
1673                if let Some(version) = secret.versions.get_mut(remove_vid) {
1674                    version.stages.retain(|s| s != &version_stage);
1675                }
1676            }
1677            if let Some(ref move_vid) = move_to {
1678                if let Some(version) = secret.versions.get_mut(move_vid) {
1679                    if !version.stages.contains(&version_stage) {
1680                        version.stages.push(version_stage.clone());
1681                    }
1682                }
1683            }
1684        }
1685
1686        let response = json!({
1687            "ARN": secret.arn,
1688            "Name": secret.name,
1689        });
1690
1691        Ok(AwsResponse::ok_json(response))
1692    }
1693
1694    fn batch_get_secret_value(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1695        let body = req.json_body();
1696        validate_optional_string_length("nextToken", body["NextToken"].as_str(), 1, 4096)?;
1697        let secret_id_list = body["SecretIdList"].as_array();
1698        let filters = body["Filters"].as_array();
1699        let max_results = body.get("MaxResults").and_then(|v| v.as_i64());
1700
1701        // Validate: can't use both SecretIdList and Filters
1702        if secret_id_list.is_some() && filters.is_some() {
1703            return Err(AwsServiceError::aws_error(
1704                StatusCode::BAD_REQUEST,
1705                "InvalidParameterException",
1706                "Either 'SecretIdList' or 'Filters' must be provided, but not both.",
1707            ));
1708        }
1709
1710        // Validate: MaxResults requires Filters
1711        if max_results.is_some() && filters.is_none() {
1712            return Err(AwsServiceError::aws_error(
1713                StatusCode::BAD_REQUEST,
1714                "InvalidParameterException",
1715                "'Filters' not specified. 'Filters' must also be specified when 'MaxResults' is provided.",
1716            ));
1717        }
1718
1719        let accounts = self.state.read();
1720        let empty = SecretsManagerState::new(&req.account_id, &req.region);
1721        let state = accounts.get(&req.account_id).unwrap_or(&empty);
1722        let mut secret_values: Vec<Value> = Vec::new();
1723        let mut errors: Vec<Value> = Vec::new();
1724        let mut next_token: Option<String> = None;
1725
1726        if let Some(id_list) = secret_id_list {
1727            for id_val in id_list {
1728                let sid = id_val.as_str().unwrap_or("");
1729                match self.find_secret_ref(state, sid) {
1730                    Ok(secret) => {
1731                        if secret.deleted {
1732                            errors.push(json!({
1733                                "SecretId": sid,
1734                                "ErrorCode": "InvalidRequestException",
1735                                "Message": "Secret is currently marked deleted. Secret can be recovered with RestoreSecret. Secret is currently marked deleted.",
1736                            }));
1737                        } else if let Some(ref current_vid) = secret.current_version_id {
1738                            if let Some(version) = secret.versions.get(current_vid) {
1739                                let mut entry = json!({
1740                                    "ARN": secret.arn,
1741                                    "Name": secret.name,
1742                                    "VersionId": version.version_id,
1743                                    "VersionStages": version.stages,
1744                                    "CreatedDate": version.created_at.timestamp_millis() as f64 / 1000.0,
1745                                });
1746                                if let Some(ref s) = version.secret_string {
1747                                    // Decrypt the same way GetSecretValue does;
1748                                    // pushing the raw stored value leaked
1749                                    // ciphertext when a KMS hook + KmsKeyId were
1750                                    // configured (bug-audit 2026-06-20, 1.10).
1751                                    let plaintext = self
1752                                        .maybe_decrypt_secret_string(
1753                                            &req.account_id,
1754                                            &secret.arn,
1755                                            secret.kms_key_id.as_deref(),
1756                                            Some(s.as_str()),
1757                                        )
1758                                        .unwrap_or_else(|| s.clone());
1759                                    entry["SecretString"] = json!(plaintext);
1760                                }
1761                                if let Some(ref b) = version.secret_binary {
1762                                    entry["SecretBinary"] = json!(base64_encode(b));
1763                                }
1764                                secret_values.push(entry);
1765                            } else {
1766                                errors.push(json!({
1767                                    "SecretId": sid,
1768                                    "ErrorCode": "ResourceNotFoundException",
1769                                    "Message": "Secrets Manager can't find the specified secret.",
1770                                }));
1771                            }
1772                        } else {
1773                            errors.push(json!({
1774                                "SecretId": sid,
1775                                "ErrorCode": "ResourceNotFoundException",
1776                                "Message": "Secrets Manager can't find the specified secret.",
1777                            }));
1778                        }
1779                    }
1780                    Err(_) => {
1781                        errors.push(json!({
1782                            "SecretId": sid,
1783                            "ErrorCode": "ResourceNotFoundException",
1784                            "Message": "Secrets Manager can't find the specified secret.",
1785                        }));
1786                    }
1787                }
1788            }
1789        } else if let Some(filters) = filters {
1790            // Get secrets matching filters
1791            let matching: Vec<&Secret> = state
1792                .secrets
1793                .values()
1794                .filter(|s| {
1795                    if s.deleted {
1796                        return false;
1797                    }
1798                    for filter in filters {
1799                        let key = filter["Key"].as_str().unwrap_or("");
1800                        let values: Vec<&str> = filter["Values"]
1801                            .as_array()
1802                            .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
1803                            .unwrap_or_default();
1804                        let matches = match key {
1805                            "name" => filter_name(s, &values),
1806                            "description" => filter_description(s, &values),
1807                            "tag-key" => filter_tag_key(s, &values),
1808                            "tag-value" => filter_tag_value(s, &values),
1809                            "all" => filter_all(s, &values),
1810                            _ => true,
1811                        };
1812                        if !matches {
1813                            return false;
1814                        }
1815                    }
1816                    true
1817                })
1818                .collect();
1819
1820            let limit = max_results.unwrap_or(100) as usize;
1821            let mut no_value_found = false;
1822            let mut matching = matching;
1823            matching.sort_by(|a, b| a.name.cmp(&b.name));
1824
1825            // Resume from the incoming NextToken (an opaque base64 offset into
1826            // the sorted result set); skip consumed entries and emit a new token
1827            // when more remain. Previously the extra results were silently
1828            // dropped and no NextToken was ever returned.
1829            let start = body["NextToken"]
1830                .as_str()
1831                .and_then(|t| t.parse::<usize>().ok())
1832                .unwrap_or(0);
1833            let end = (start + limit).min(matching.len());
1834            if end < matching.len() {
1835                next_token = Some(end.to_string());
1836            }
1837
1838            for secret in matching.iter().skip(start).take(limit) {
1839                if let Some(ref current_vid) = secret.current_version_id {
1840                    if let Some(version) = secret.versions.get(current_vid) {
1841                        let mut entry = json!({
1842                            "ARN": secret.arn,
1843                            "Name": secret.name,
1844                            "VersionId": version.version_id,
1845                            "VersionStages": version.stages,
1846                            "CreatedDate": version.created_at.timestamp_millis() as f64 / 1000.0,
1847                        });
1848                        if let Some(ref s) = version.secret_string {
1849                            // Decrypt like GetSecretValue; the raw stored value
1850                            // is ciphertext under a configured KMS hook (1.10).
1851                            let plaintext = self
1852                                .maybe_decrypt_secret_string(
1853                                    &req.account_id,
1854                                    &secret.arn,
1855                                    secret.kms_key_id.as_deref(),
1856                                    Some(s.as_str()),
1857                                )
1858                                .unwrap_or_else(|| s.clone());
1859                            entry["SecretString"] = json!(plaintext);
1860                        }
1861                        if let Some(ref b) = version.secret_binary {
1862                            entry["SecretBinary"] = json!(base64_encode(b));
1863                        }
1864                        secret_values.push(entry);
1865                    } else {
1866                        no_value_found = true;
1867                    }
1868                } else {
1869                    no_value_found = true;
1870                }
1871            }
1872
1873            if no_value_found && secret_values.is_empty() {
1874                return Err(AwsServiceError::aws_error(
1875                    StatusCode::NOT_FOUND,
1876                    "ResourceNotFoundException",
1877                    "Secrets Manager can't find the specified secret.",
1878                ));
1879            }
1880        }
1881
1882        let mut response = json!({
1883            "SecretValues": secret_values,
1884            "Errors": errors,
1885        });
1886
1887        // Remove empty arrays
1888        if errors.is_empty() {
1889            response.as_object_mut().unwrap().remove("Errors");
1890        }
1891        if let Some(token) = next_token {
1892            response
1893                .as_object_mut()
1894                .unwrap()
1895                .insert("NextToken".to_string(), json!(token));
1896        }
1897
1898        Ok(AwsResponse::ok_json(response))
1899    }
1900
1901    fn get_resource_policy(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1902        let body = req.json_body();
1903        let secret_id = require_secret_id(&body)?;
1904
1905        let accounts = self.state.read();
1906        let empty = SecretsManagerState::new(&req.account_id, &req.region);
1907        let state = accounts.get(&req.account_id).unwrap_or(&empty);
1908        let secret = self.find_secret_ref(state, &secret_id)?;
1909
1910        // Real AWS omits ResourcePolicy when none is attached; terraform
1911        // provider and the SDK choke on an empty-string policy.
1912        let mut response = json!({
1913            "ARN": secret.arn,
1914            "Name": secret.name,
1915        });
1916        if let Some(ref policy) = secret.resource_policy {
1917            response["ResourcePolicy"] = json!(policy);
1918        }
1919
1920        Ok(AwsResponse::ok_json(response))
1921    }
1922
1923    fn validate_resource_policy(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1924        let body = req.json_body();
1925        validate_optional_string_length("secretId", body["SecretId"].as_str(), 1, 2048)?;
1926        validate_required("ResourcePolicy", &body["ResourcePolicy"])?;
1927        let policy_str = body["ResourcePolicy"].as_str().ok_or_else(|| {
1928            AwsServiceError::aws_error(
1929                StatusCode::BAD_REQUEST,
1930                "InvalidParameterException",
1931                "ResourcePolicy must be a string",
1932            )
1933        })?;
1934        validate_string_length("resourcePolicy", policy_str, 1, 20480)?;
1935
1936        // If SecretId is provided, verify the secret exists
1937        if let Some(secret_id) = body["SecretId"].as_str() {
1938            let accounts = self.state.read();
1939            let empty = SecretsManagerState::new(&req.account_id, &req.region);
1940            let state = accounts.get(&req.account_id).unwrap_or(&empty);
1941            self.find_secret_key(state, secret_id)?;
1942        }
1943
1944        let response = json!({
1945            "PolicyValidationPassed": true,
1946            "ValidationErrors": [],
1947        });
1948        Ok(AwsResponse::ok_json(response))
1949    }
1950
1951    fn put_resource_policy(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1952        let body = req.json_body();
1953        let secret_id = require_secret_id(&body)?;
1954        validate_required("ResourcePolicy", &body["ResourcePolicy"])?;
1955        validate_optional_string_length(
1956            "resourcePolicy",
1957            body["ResourcePolicy"].as_str(),
1958            1,
1959            20480,
1960        )?;
1961        let policy = body["ResourcePolicy"].as_str().map(|s| s.to_string());
1962
1963        let mut accounts = self.state.write();
1964        let state = accounts.get_or_create(&req.account_id);
1965        let secret = self.find_secret_mut(state, &secret_id)?;
1966        secret.resource_policy = policy;
1967
1968        let response = json!({
1969            "ARN": secret.arn,
1970            "Name": secret.name,
1971        });
1972
1973        Ok(AwsResponse::ok_json(response))
1974    }
1975
1976    fn delete_resource_policy(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1977        let body = req.json_body();
1978        let secret_id = require_secret_id(&body)?;
1979
1980        let mut accounts = self.state.write();
1981        let state = accounts.get_or_create(&req.account_id);
1982        let secret = self.find_secret_mut(state, &secret_id)?;
1983        secret.resource_policy = None;
1984
1985        let response = json!({
1986            "ARN": secret.arn,
1987            "Name": secret.name,
1988        });
1989
1990        Ok(AwsResponse::ok_json(response))
1991    }
1992
1993    fn replicate_secret_to_regions(
1994        &self,
1995        req: &AwsRequest,
1996    ) -> Result<AwsResponse, AwsServiceError> {
1997        let body = req.json_body();
1998        let secret_id = require_secret_id(&body)?;
1999        // AddReplicaRegions[].Region — the regions to replicate into.
2000        let add_regions: Vec<String> = body["AddReplicaRegions"]
2001            .as_array()
2002            .map(|arr| {
2003                arr.iter()
2004                    .filter_map(|r| r["Region"].as_str().map(String::from))
2005                    .collect()
2006            })
2007            .unwrap_or_default();
2008
2009        let mut accounts = self.state.write();
2010        let state = accounts.get_or_create(&req.account_id);
2011        let secret = self.find_secret_mut(state, &secret_id)?;
2012        for region in add_regions {
2013            if !secret.replica_regions.contains(&region) {
2014                secret.replica_regions.push(region);
2015            }
2016        }
2017        let response = json!({
2018            "ARN": secret.arn,
2019            "ReplicationStatus": replication_status_json(&secret.replica_regions),
2020        });
2021        Ok(AwsResponse::ok_json(response))
2022    }
2023
2024    fn remove_regions_from_replication(
2025        &self,
2026        req: &AwsRequest,
2027    ) -> Result<AwsResponse, AwsServiceError> {
2028        let body = req.json_body();
2029        let secret_id = require_secret_id(&body)?;
2030        let remove_regions: Vec<String> = body["RemoveReplicaRegions"]
2031            .as_array()
2032            .map(|arr| {
2033                arr.iter()
2034                    .filter_map(|r| r.as_str().map(String::from))
2035                    .collect()
2036            })
2037            .unwrap_or_default();
2038
2039        let mut accounts = self.state.write();
2040        let state = accounts.get_or_create(&req.account_id);
2041        let secret = self.find_secret_mut(state, &secret_id)?;
2042        secret
2043            .replica_regions
2044            .retain(|r| !remove_regions.contains(r));
2045        let response = json!({
2046            "ARN": secret.arn,
2047            "ReplicationStatus": replication_status_json(&secret.replica_regions),
2048        });
2049        Ok(AwsResponse::ok_json(response))
2050    }
2051
2052    fn stop_replication_to_replica(
2053        &self,
2054        req: &AwsRequest,
2055    ) -> Result<AwsResponse, AwsServiceError> {
2056        let body = req.json_body();
2057        let secret_id = require_secret_id(&body)?;
2058
2059        let accounts = self.state.read();
2060        let empty = SecretsManagerState::new(&req.account_id, &req.region);
2061        let state = accounts.get(&req.account_id).unwrap_or(&empty);
2062        let secret = self.find_secret_ref(state, &secret_id)?;
2063
2064        let response = json!({
2065            "ARN": secret.arn,
2066        });
2067        Ok(AwsResponse::ok_json(response))
2068    }
2069
2070    /// Find a secret by name, full ARN, or partial ARN (mutable).
2071    fn find_secret_mut<'a>(
2072        &self,
2073        state: &'a mut crate::state::SecretsManagerState,
2074        secret_id: &str,
2075    ) -> Result<&'a mut Secret, AwsServiceError> {
2076        let key = self.find_secret_key(state, secret_id)?;
2077        Ok(state.secrets.get_mut(&key).unwrap())
2078    }
2079
2080    fn find_secret_key(
2081        &self,
2082        state: &crate::state::SecretsManagerState,
2083        secret_id: &str,
2084    ) -> Result<String, AwsServiceError> {
2085        let key = if state.secrets.contains_key(secret_id) {
2086            Some(secret_id.to_string())
2087        } else if let Some(secret) = state.secrets.values().find(|s| s.arn == secret_id) {
2088            Some(secret.name.clone())
2089        } else if secret_id.starts_with("arn:aws:secretsmanager:") {
2090            state
2091                .secrets
2092                .values()
2093                .find(|s| s.arn.starts_with(secret_id))
2094                .map(|s| s.name.clone())
2095        } else {
2096            None
2097        };
2098
2099        match key {
2100            // A secret whose recovery window has elapsed is treated as gone.
2101            Some(key)
2102                if state
2103                    .secrets
2104                    .get(&key)
2105                    .map(|s| secret_recovery_window_elapsed(s, Utc::now()))
2106                    .unwrap_or(false) =>
2107            {
2108                Err(secret_not_found())
2109            }
2110            Some(key) => Ok(key),
2111            None => Err(secret_not_found()),
2112        }
2113    }
2114
2115    /// Find a secret by name, full ARN, or partial ARN (immutable).
2116    fn find_secret_ref<'a>(
2117        &self,
2118        state: &'a crate::state::SecretsManagerState,
2119        secret_id: &str,
2120    ) -> Result<&'a Secret, AwsServiceError> {
2121        let secret = state
2122            .secrets
2123            .get(secret_id)
2124            .or_else(|| state.secrets.values().find(|s| s.arn == secret_id))
2125            .or_else(|| {
2126                if secret_id.starts_with("arn:aws:secretsmanager:") {
2127                    state
2128                        .secrets
2129                        .values()
2130                        .find(|s| s.arn.starts_with(secret_id))
2131                } else {
2132                    None
2133                }
2134            });
2135
2136        match secret {
2137            // A secret whose recovery window has elapsed is treated as gone.
2138            Some(secret) if secret_recovery_window_elapsed(secret, Utc::now()) => {
2139                Err(secret_not_found())
2140            }
2141            Some(secret) => Ok(secret),
2142            None => Err(secret_not_found()),
2143        }
2144    }
2145}
2146
2147/// The standard "secret can't be found" error, shared by the resolution helpers.
2148fn secret_not_found() -> AwsServiceError {
2149    AwsServiceError::aws_error(
2150        StatusCode::NOT_FOUND,
2151        "ResourceNotFoundException",
2152        "Secrets Manager can't find the specified secret.",
2153    )
2154}
2155
2156/// Persist the current Secrets Manager state as a snapshot. Offloads the
2157/// serialization and blocking file write to the Tokio blocking pool. Noop when
2158/// `store` is `None` (memory mode). Shared by
2159/// `SecretsManagerService::save_snapshot` and
2160/// the CloudFormation provisioner's post-provision persist hook so both route
2161/// through the same serialize-and-write path.
2162pub async fn save_secretsmanager_snapshot(
2163    state: &SharedSecretsManagerState,
2164    store: Option<Arc<dyn SnapshotStore>>,
2165    lock: &AsyncMutex<()>,
2166) {
2167    let Some(store) = store else {
2168        return;
2169    };
2170    let _guard = lock.lock().await;
2171    let snapshot = SecretsManagerSnapshot {
2172        schema_version: SECRETSMANAGER_SNAPSHOT_SCHEMA_VERSION,
2173        state: None,
2174        accounts: Some(state.read().clone()),
2175    };
2176    let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
2177        let bytes = serde_json::to_vec(&snapshot)
2178            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
2179        store.save(&bytes)
2180    })
2181    .await;
2182    match join {
2183        Ok(Ok(())) => {}
2184        Ok(Err(err)) => tracing::error!(%err, "failed to write secretsmanager snapshot"),
2185        Err(err) => tracing::error!(%err, "secretsmanager snapshot task panicked"),
2186    }
2187}
2188
2189/// Parsed + validated inputs for `CreateSecret`.
2190struct CreateSecretInput {
2191    name: String,
2192    client_request_token: Option<String>,
2193    description: Option<String>,
2194    kms_key_id: Option<String>,
2195    secret_string: Option<String>,
2196    secret_binary: Option<Vec<u8>>,
2197    tags: Vec<(String, String)>,
2198    /// Regions requested via CreateSecret's `AddReplicaRegions`. Persisted
2199    /// as replica regions so DescribeSecret and the CreateSecret response
2200    /// both report `ReplicationStatus`.
2201    add_replica_regions: Vec<String>,
2202}
2203
2204impl CreateSecretInput {
2205    fn from_body(body: &Value) -> Result<Self, AwsServiceError> {
2206        validate_required("Name", &body["Name"])?;
2207        let name = body["Name"]
2208            .as_str()
2209            .ok_or_else(|| {
2210                AwsServiceError::aws_error(
2211                    StatusCode::BAD_REQUEST,
2212                    "InvalidParameterException",
2213                    "Name is required",
2214                )
2215            })?
2216            .to_string();
2217        validate_string_length("name", &name, 1, 512)?;
2218        validate_secret_name_charset(&name)?;
2219        validate_optional_string_length(
2220            "clientRequestToken",
2221            body["ClientRequestToken"].as_str(),
2222            32,
2223            64,
2224        )?;
2225        validate_optional_string_length("description", body["Description"].as_str(), 0, 2048)?;
2226        validate_optional_string_length("kmsKeyId", body["KmsKeyId"].as_str(), 0, 2048)?;
2227        validate_optional_string_length("secretString", body["SecretString"].as_str(), 1, 65536)?;
2228
2229        Ok(Self {
2230            name,
2231            client_request_token: body["ClientRequestToken"].as_str().map(|s| s.to_string()),
2232            description: body["Description"].as_str().map(|s| s.to_string()),
2233            kms_key_id: body["KmsKeyId"].as_str().map(|s| s.to_string()),
2234            secret_string: body["SecretString"].as_str().map(|s| s.to_string()),
2235            secret_binary: parse_secret_binary(body)?,
2236            tags: parse_tags(&body["Tags"]),
2237            add_replica_regions: body["AddReplicaRegions"]
2238                .as_array()
2239                .map(|arr| {
2240                    arr.iter()
2241                        .filter_map(|r| r["Region"].as_str().map(String::from))
2242                        .collect()
2243                })
2244                .unwrap_or_default(),
2245        })
2246    }
2247}
2248
2249#[async_trait]
2250impl AwsService for SecretsManagerService {
2251    fn service_name(&self) -> &str {
2252        "secretsmanager"
2253    }
2254
2255    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2256        let mutates = is_mutating_action(req.action.as_str());
2257        let result = match req.action.as_str() {
2258            "CreateSecret" => self.create_secret(&req),
2259            "GetSecretValue" => self.get_secret_value(&req),
2260            "PutSecretValue" => self.put_secret_value(&req),
2261            "UpdateSecret" => self.update_secret(&req),
2262            "DeleteSecret" => self.delete_secret(&req),
2263            "RestoreSecret" => self.restore_secret(&req),
2264            "DescribeSecret" => self.describe_secret(&req),
2265            "ListSecrets" => self.list_secrets(&req),
2266            "TagResource" => self.tag_resource(&req),
2267            "UntagResource" => self.untag_resource(&req),
2268            "ListSecretVersionIds" => self.list_secret_version_ids(&req),
2269            "GetRandomPassword" => self.get_random_password(&req),
2270            "RotateSecret" => {
2271                let (response, invocation) = self.rotate_secret(&req)?;
2272                if let Some(inv) = invocation {
2273                    if let Some(cleanup) = inv.cleanup_pending.as_ref() {
2274                        // Test-only rotation (RotateImmediately=false): run the
2275                        // testSecret invocation and remove the temporary
2276                        // AWSPENDING version SYNCHRONOUSLY, inside the handler's
2277                        // critical section, so the mutating-action snapshot
2278                        // taken at the end of `handle` never captures (and can't
2279                        // restore) the temporary version.
2280                        if let Some(ref bus) = self.delivery_bus {
2281                            run_rotation_steps(bus, &inv).await;
2282                        }
2283                        remove_rotation_test_pending(&self.state, cleanup);
2284                    } else {
2285                        // Full rotation: AWS invokes the rotation Lambda
2286                        // asynchronously for each step. The Lambda's own
2287                        // PutSecretValue calls persist their own snapshots.
2288                        let bus = self.delivery_bus.clone();
2289                        tokio::spawn(async move {
2290                            if let Some(bus) = bus {
2291                                run_rotation_steps(&bus, &inv).await;
2292                            }
2293                        });
2294                    }
2295                }
2296                Ok(response)
2297            }
2298            "CancelRotateSecret" => self.cancel_rotate_secret(&req),
2299            "UpdateSecretVersionStage" => self.update_secret_version_stage(&req),
2300            "BatchGetSecretValue" => self.batch_get_secret_value(&req),
2301            "GetResourcePolicy" => self.get_resource_policy(&req),
2302            "PutResourcePolicy" => self.put_resource_policy(&req),
2303            "DeleteResourcePolicy" => self.delete_resource_policy(&req),
2304            "ValidateResourcePolicy" => self.validate_resource_policy(&req),
2305            "ReplicateSecretToRegions" => self.replicate_secret_to_regions(&req),
2306            "RemoveRegionsFromReplication" => self.remove_regions_from_replication(&req),
2307            "StopReplicationToReplica" => self.stop_replication_to_replica(&req),
2308            _ => Err(AwsServiceError::action_not_implemented(
2309                "secretsmanager",
2310                &req.action,
2311            )),
2312        };
2313        if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
2314            self.save_snapshot().await;
2315        }
2316        result.map_err(remap_validation_error)
2317    }
2318
2319    fn supported_actions(&self) -> &[&str] {
2320        &[
2321            "CreateSecret",
2322            "GetSecretValue",
2323            "PutSecretValue",
2324            "UpdateSecret",
2325            "DeleteSecret",
2326            "RestoreSecret",
2327            "DescribeSecret",
2328            "ListSecrets",
2329            "TagResource",
2330            "UntagResource",
2331            "ListSecretVersionIds",
2332            "GetRandomPassword",
2333            "RotateSecret",
2334            "CancelRotateSecret",
2335            "UpdateSecretVersionStage",
2336            "BatchGetSecretValue",
2337            "GetResourcePolicy",
2338            "PutResourcePolicy",
2339            "DeleteResourcePolicy",
2340            "ValidateResourcePolicy",
2341            "ReplicateSecretToRegions",
2342            "RemoveRegionsFromReplication",
2343            "StopReplicationToReplica",
2344        ]
2345    }
2346}
2347
2348#[path = "service_helpers.rs"]
2349mod service_helpers;
2350pub(crate) use service_helpers::*;
2351
2352/// The shared `fakecloud_core::validation` helpers raise
2353/// `ValidationException`, but the Secrets Manager Smithy model does not
2354/// declare `ValidationException` on any operation — its declared input
2355/// error is `InvalidParameterException`. Translate at the dispatcher
2356/// boundary so the wire-level error code matches real AWS without
2357/// duplicating every validator.
2358/// Build the `ReplicationStatus` array from a secret's replica regions.
2359/// Each replica reports `InSync`, matching a healthy replication.
2360fn replication_status_json(regions: &[String]) -> Value {
2361    Value::Array(
2362        regions
2363            .iter()
2364            .map(|r| {
2365                json!({
2366                    "Region": r,
2367                    "Status": "InSync",
2368                    "StatusMessage": "Replication succeeded",
2369                })
2370            })
2371            .collect(),
2372    )
2373}
2374
2375fn remap_validation_error(err: AwsServiceError) -> AwsServiceError {
2376    match err {
2377        AwsServiceError::AwsError {
2378            status,
2379            code,
2380            message,
2381            extra_fields,
2382            headers,
2383        } if code == "ValidationException" => AwsServiceError::AwsError {
2384            status,
2385            code: "InvalidParameterException".to_string(),
2386            message,
2387            extra_fields,
2388            headers,
2389        },
2390        other => other,
2391    }
2392}
2393
2394/// Extract the owning account-id from an `arn:aws:secretsmanager:...:ACCOUNT:secret:...`
2395/// secret id. Returns `caller_account` when the input is a bare name
2396/// or a same-account ARN.
2397fn secret_owner_account(secret_id: &str, caller_account: &str) -> String {
2398    if !secret_id.starts_with("arn:aws:secretsmanager:") {
2399        return caller_account.to_string();
2400    }
2401    let parts: Vec<&str> = secret_id.splitn(7, ':').collect();
2402    if parts.len() < 5 {
2403        return caller_account.to_string();
2404    }
2405    let account = parts[4];
2406    if account.is_empty() {
2407        caller_account.to_string()
2408    } else {
2409        account.to_string()
2410    }
2411}
2412
2413/// Evaluate a Secrets Manager resource policy against a cross-account
2414/// caller. Empty policy implicitly denies (real AWS behaviour). Uses
2415/// the shared IAM evaluator so the same policy semantics apply
2416/// service-wide.
2417fn resource_policy_allows(policy_doc: &str, caller_account: &str, secret_arn: &str) -> bool {
2418    if policy_doc.is_empty() {
2419        return false;
2420    }
2421    use fakecloud_core::auth::{Principal, PrincipalType};
2422    use fakecloud_iam::evaluator::{evaluate, EvalRequest, PolicyDocument};
2423    let doc = PolicyDocument::parse(policy_doc);
2424    let principal_arn = Arn::global("iam", caller_account, "root").to_string();
2425    let principal = Principal {
2426        arn: principal_arn.clone(),
2427        user_id: principal_arn.clone(),
2428        account_id: caller_account.to_string(),
2429        principal_type: PrincipalType::User,
2430        source_identity: None,
2431        tags: None,
2432    };
2433    let req = EvalRequest {
2434        principal: &principal,
2435        action: "secretsmanager:GetSecretValue".to_string(),
2436        resource: secret_arn.to_string(),
2437        context: Default::default(),
2438    };
2439    matches!(
2440        evaluate(&[doc], &req),
2441        fakecloud_iam::evaluator::Decision::Allow
2442    )
2443}
2444
2445#[cfg(test)]
2446#[path = "service_tests.rs"]
2447mod tests;