Skip to content

Commit 39936ea

Browse files
authored
fix(sqs,iam): stop two data-destruction paths (bug-hunt) (#2275)
2 parents a57b722 + 4615f15 commit 39936ea

5 files changed

Lines changed: 242 additions & 9 deletions

File tree

crates/fakecloud-e2e/tests/iam.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,100 @@ async fn iam_group_lifecycle() {
565565
assert!(list.groups().is_empty());
566566
}
567567

568+
/// Regression: DeleteGroup on a group that still has members must return a
569+
/// 409 DeleteConflict, NOT silently destroy the group and its membership.
570+
#[tokio::test]
571+
async fn iam_delete_nonempty_group_conflicts() {
572+
let server = TestServer::start().await;
573+
let client = server.iam_client().await;
574+
575+
client
576+
.create_group()
577+
.group_name("payroll")
578+
.send()
579+
.await
580+
.unwrap();
581+
client
582+
.create_user()
583+
.user_name("carol")
584+
.send()
585+
.await
586+
.unwrap();
587+
client
588+
.add_user_to_group()
589+
.group_name("payroll")
590+
.user_name("carol")
591+
.send()
592+
.await
593+
.unwrap();
594+
595+
// Member present -> DeleteConflict, group preserved.
596+
let err = client
597+
.delete_group()
598+
.group_name("payroll")
599+
.send()
600+
.await
601+
.expect_err("deleting a non-empty group must fail");
602+
assert!(
603+
format!("{err:?}").contains("DeleteConflict"),
604+
"expected DeleteConflict, got: {err:?}"
605+
);
606+
assert_eq!(
607+
client.list_groups().send().await.unwrap().groups().len(),
608+
1,
609+
"group must still exist after a rejected delete"
610+
);
611+
612+
// Also blocked by an inline policy after the member is removed.
613+
client
614+
.remove_user_from_group()
615+
.group_name("payroll")
616+
.user_name("carol")
617+
.send()
618+
.await
619+
.unwrap();
620+
client
621+
.put_group_policy()
622+
.group_name("payroll")
623+
.policy_name("inline")
624+
.policy_document(r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}"#)
625+
.send()
626+
.await
627+
.unwrap();
628+
let err = client
629+
.delete_group()
630+
.group_name("payroll")
631+
.send()
632+
.await
633+
.expect_err("deleting a group with an inline policy must fail");
634+
assert!(
635+
format!("{err:?}").contains("DeleteConflict"),
636+
"expected DeleteConflict for inline policy, got: {err:?}"
637+
);
638+
639+
// Clear the policy -> delete succeeds.
640+
client
641+
.delete_group_policy()
642+
.group_name("payroll")
643+
.policy_name("inline")
644+
.send()
645+
.await
646+
.unwrap();
647+
client
648+
.delete_group()
649+
.group_name("payroll")
650+
.send()
651+
.await
652+
.unwrap();
653+
assert!(client
654+
.list_groups()
655+
.send()
656+
.await
657+
.unwrap()
658+
.groups()
659+
.is_empty());
660+
}
661+
568662
// ---- IAM Instance Profile Tests ----
569663

570664
#[tokio::test]

crates/fakecloud-e2e/tests/sqs_kms.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,70 @@ async fn sqs_send_receive_encrypted_round_trips_through_kms() {
7878
);
7979
}
8080

81+
/// Regression: ReceiveMessage against an SSE-KMS queue whose key became
82+
/// unusable (disabled) must return an error WITHOUT destroying the batch.
83+
/// Previously the messages were popped out of the queue before the decrypt
84+
/// `?` unwound, permanently losing them. Real SQS leaves them visible.
85+
#[tokio::test]
86+
async fn sqs_receive_decrypt_failure_does_not_destroy_messages() {
87+
let server = TestServer::start().await;
88+
let sqs = server.sqs_client().await;
89+
let kms = server.kms_client().await;
90+
91+
// Customer-managed key we can disable (managed alias/aws/sqs can't be).
92+
let key = kms.create_key().send().await.unwrap();
93+
let key_id = key.key_metadata().unwrap().key_id().to_string();
94+
95+
let queue = sqs
96+
.create_queue()
97+
.queue_name("decrypt-fail")
98+
.attributes(
99+
aws_sdk_sqs::types::QueueAttributeName::KmsMasterKeyId,
100+
&key_id,
101+
)
102+
.send()
103+
.await
104+
.unwrap();
105+
let queue_url = queue.queue_url().unwrap().to_string();
106+
107+
sqs.send_message()
108+
.queue_url(&queue_url)
109+
.message_body("do-not-lose-me")
110+
.send()
111+
.await
112+
.unwrap();
113+
114+
// Disable the key: the next receive must fail to decrypt.
115+
kms.disable_key().key_id(&key_id).send().await.unwrap();
116+
let failed = sqs
117+
.receive_message()
118+
.queue_url(&queue_url)
119+
.max_number_of_messages(1)
120+
.send()
121+
.await;
122+
assert!(
123+
failed.is_err(),
124+
"ReceiveMessage must error when the KMS key is disabled, got: {failed:?}"
125+
);
126+
127+
// Re-enable the key: the message must still be there (not destroyed).
128+
kms.enable_key().key_id(&key_id).send().await.unwrap();
129+
let recovered = sqs
130+
.receive_message()
131+
.queue_url(&queue_url)
132+
.max_number_of_messages(1)
133+
.send()
134+
.await
135+
.unwrap();
136+
let msgs = recovered.messages();
137+
assert_eq!(
138+
msgs.len(),
139+
1,
140+
"message must survive a decrypt failure and be receivable after the key is re-enabled"
141+
);
142+
assert_eq!(msgs[0].body(), Some("do-not-lose-me"));
143+
}
144+
81145
#[tokio::test]
82146
async fn sqs_unencrypted_queue_does_not_record_kms_usage() {
83147
use aws_sdk_sqs::types::QueueAttributeName;

crates/fakecloud-iam/src/iam_service/groups.rs

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -199,14 +199,46 @@ impl IamService {
199199
let mut accounts = self.state.write();
200200
let state = accounts.get_or_create(&req.account_id);
201201

202-
if state.groups.remove(&group_name).is_none() {
202+
// AWS rejects DeleteGroup with a 409 DeleteConflict while the group
203+
// still has members or attached/inline policies — it does NOT silently
204+
// destroy the group and its membership. Guard before removing.
205+
let (has_members, has_attached, has_inline) = {
206+
let group = state.groups.get(&group_name).ok_or_else(|| {
207+
AwsServiceError::aws_error(
208+
StatusCode::NOT_FOUND,
209+
"NoSuchEntity",
210+
format!("The group with name {group_name} cannot be found."),
211+
)
212+
})?;
213+
(
214+
!group.members.is_empty(),
215+
!group.attached_policies.is_empty(),
216+
!group.inline_policies.is_empty(),
217+
)
218+
};
219+
if has_members {
203220
return Err(AwsServiceError::aws_error(
204-
StatusCode::NOT_FOUND,
205-
"NoSuchEntity",
206-
format!("The group with name {group_name} cannot be found."),
221+
StatusCode::CONFLICT,
222+
"DeleteConflict",
223+
"Cannot delete entity, must remove users from the group first.".to_string(),
224+
));
225+
}
226+
if has_attached {
227+
return Err(AwsServiceError::aws_error(
228+
StatusCode::CONFLICT,
229+
"DeleteConflict",
230+
"Cannot delete entity, must detach all policies first.".to_string(),
231+
));
232+
}
233+
if has_inline {
234+
return Err(AwsServiceError::aws_error(
235+
StatusCode::CONFLICT,
236+
"DeleteConflict",
237+
"Cannot delete entity, must delete policies first.".to_string(),
207238
));
208239
}
209240

241+
state.groups.remove(&group_name);
210242
let xml = empty_response("DeleteGroup", &req.request_id);
211243
Ok(AwsResponse::xml(StatusCode::OK, xml))
212244
}

crates/fakecloud-kms/src/hook.rs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,18 @@ pub enum KmsHookError {
7676
/// Ciphertext envelope is malformed or signed by a key that no
7777
/// longer exists.
7878
InvalidCiphertext(String),
79+
/// The key resolves but is disabled / pending deletion, so it can't
80+
/// be used for a cryptographic operation (mirrors real KMS, which
81+
/// fails Decrypt with `DisabledException` / `KMSInvalidStateException`).
82+
KeyDisabled(String),
7983
}
8084

8185
impl std::fmt::Display for KmsHookError {
8286
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
8387
match self {
8488
Self::KeyNotFound(k) => write!(f, "kms key not found: {k}"),
8589
Self::InvalidCiphertext(msg) => write!(f, "invalid ciphertext: {msg}"),
90+
Self::KeyDisabled(k) => write!(f, "kms key is disabled: {k}"),
8691
}
8792
}
8893
}
@@ -181,11 +186,17 @@ impl KmsServiceHook {
181186
let state = mas
182187
.get(account_id)
183188
.ok_or_else(|| KmsHookError::KeyNotFound(key_short.clone()))?;
184-
state
189+
let key = state
185190
.keys
186191
.get(&key_short)
187-
.map(|k| k.arn.clone())
188-
.ok_or_else(|| KmsHookError::KeyNotFound(key_short.clone()))?
192+
.ok_or_else(|| KmsHookError::KeyNotFound(key_short.clone()))?;
193+
// A disabled / pending-deletion key can't decrypt — real KMS
194+
// rejects the operation, and SSE-KMS consumers (SQS/SNS/...) must
195+
// surface that rather than returning stale ciphertext as plaintext.
196+
if !key.enabled {
197+
return Err(KmsHookError::KeyDisabled(key_short.clone()));
198+
}
199+
key.arn.clone()
189200
};
190201

191202
self.usage.write().push(KmsUsageRecord {

crates/fakecloud-sqs/src/service/messages.rs

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -737,9 +737,41 @@ impl SqsService {
737737
// already consumed even though the caller saw an error.
738738
let mut plaintext_bodies: Vec<String> = Vec::with_capacity(received.len());
739739
if kms_key_id.is_some() && self.kms_hook.is_some() {
740+
let mut decrypt_err = None;
740741
for msg in received.iter() {
741-
let plaintext = self.decrypt_message_body(account_id, &queue_arn, &msg.body)?;
742-
plaintext_bodies.push(plaintext);
742+
match self.decrypt_message_body(account_id, &queue_arn, &msg.body) {
743+
Ok(plaintext) => plaintext_bodies.push(plaintext),
744+
Err(e) => {
745+
decrypt_err = Some(e);
746+
break;
747+
}
748+
}
749+
}
750+
// A decrypt failure (KMS key disabled / pending-deletion / deleted)
751+
// must NOT destroy the batch: the messages were already popped out
752+
// of `queue.messages` but not yet pushed onto `inflight`, so an
753+
// early `?` here would drop them permanently. Real SQS leaves the
754+
// messages visible and returns an error. Restore the popped
755+
// messages to the front of the queue (preserving order), undo the
756+
// receive-count bump and the receipt handle we minted, then error.
757+
if let Some(e) = decrypt_err {
758+
let restored: Vec<SqsMessage> = received
759+
.drain(..)
760+
.map(|mut m| {
761+
m.visible_at = None;
762+
m.receive_count = m.receive_count.saturating_sub(1);
763+
if let Some(handle) = m.receipt_handle.take() {
764+
if let Some(list) = queue.receipt_handle_map.get_mut(&m.message_id) {
765+
list.retain(|h| h != &handle);
766+
}
767+
}
768+
m
769+
})
770+
.collect();
771+
for m in restored.into_iter().rev() {
772+
queue.messages.push_front(m);
773+
}
774+
return Err(e);
743775
}
744776
}
745777

0 commit comments

Comments
 (0)