Compare commits

..

2 Commits

Author SHA1 Message Date
cxymds b821684caa test(ci): refresh e2e selections for duplicate CreateBucket
The two new create_bucket_region cases join the e2e-smoke and e2e-full
selections, which main also extended with multipart_upload_id tests.

smoke is measured from a local nextest listing: removing the two new cases
reproduces main's committed digest exactly. e2e-full is derived the same way
from main's listing artifact (run 41933823399, babfeaed0) plus the two
multipart_upload_id cases, which reproduces main's committed linux digest
before the new cases are added; CI re-verifies both platforms.

test_bucket_create_exists_nonowner asserts BucketAlreadyExists for a second
identity, which a single-owner deployment cannot produce. Move it to
excluded_tests.txt with the deployment-ownership rationale; the IAM-user
duplicate paths stay covered by crates/e2e_test/src/create_bucket_region_test.rs.
2026-11-05 09:30:32 +08:00
cxymds 7dde05d945 fix(s3): treat duplicate CreateBucket as owned by the requester
The duplicate-bucket response was decided from the requester's root-owner
flag, so every IAM user recreating a bucket received 409
BucketAlreadyExists, including a user recreating a bucket it created.
BucketAlreadyExists means the bucket belongs to another account; a
deployment owns a single namespace, so the answer is purely region-based:
the default region returns the legacy 200 OK, any other region returns
BucketAlreadyOwnedByYou.

Both the existing-bucket fast path and the BucketExists path now share one
helper. Authorization still runs first, so a requester without
s3:CreateBucket gets AccessDenied and cannot probe bucket existence.

Refs #8493
2026-11-05 09:17:26 +08:00
6 changed files with 197 additions and 43 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=7c705942d3a9e583ebaa85845daa21c4f81fdbf36def24db931aaef38636c53e
sha256-linux=01e1f5569be60674e30d9e30a9961c5c53a6cea6ca1f7913218f2adf7291d574
sha256-darwin=a3e166a140f3150218b4a857fedb8749940fad0c8b053d0e3c1b957321bf5beb
sha256-linux=dce4a8eb634a27b4589bcf6d2ebf248ede97aa59701acc8696391d2812a3177d
+1 -1
View File
@@ -1 +1 @@
sha256=f794c004e5952147c79dadbe6b7c84804bc986f34a8142731fac8864004e5130
sha256=5bdd4c2eadbc41625ae3184fff34062e000d328108436259b2b7c4fe2531ecaf
@@ -502,10 +502,163 @@ mod tests {
assert!(response.contains(if expected == reqwest::StatusCode::FORBIDDEN {
"<Code>AccessDenied</Code>"
} else {
"<Code>BucketAlreadyExists</Code>"
"<Code>BucketAlreadyOwnedByYou</Code>"
}));
}
}
Ok(())
}
/// A deployment owns a single bucket namespace, so an IAM user that recreates
/// an existing bucket is the owner, not another account. In the default region
/// every such request returns the legacy idempotent `200 OK` (AWS CreateBucket
/// semantics), including buckets created by root and by another IAM user.
#[tokio::test]
async fn test_create_bucket_duplicate_in_default_region_is_idempotent_for_iam_user()
-> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec!["--region", "us-east-1"]).await?;
let root = env.create_s3_client();
admin_create_user(&env, "duplicate-iam", "duplicate-iam-secret").await?;
admin_attach_user_policy_via(
AdminTransport::Signed,
&env.url,
&env.access_key,
&env.secret_key,
"readwrite",
"duplicate-iam",
)
.await?;
let iam = Client::from_conf(build_test_s3_config(
&env.url,
"duplicate-iam",
"duplicate-iam-secret",
None,
"duplicate-create",
));
root.create_bucket()
.bucket("duplicate-root-owned")
.send()
.await
.expect("root creates the first bucket");
iam.create_bucket()
.bucket("duplicate-root-owned")
.send()
.await
.expect("an IAM user recreating a root-owned bucket in us-east-1 gets 200 OK");
iam.create_bucket()
.bucket("duplicate-iam-owned")
.send()
.await
.expect("the IAM user creates its own bucket");
iam.create_bucket()
.bucket("duplicate-iam-owned")
.send()
.await
.expect("the same IAM user recreating its bucket gets 200 OK");
root.create_bucket()
.bucket("duplicate-iam-owned")
.send()
.await
.expect("root recreating an IAM-created bucket gets 200 OK");
Ok(())
}
/// Outside the default region the same ownership answer becomes
/// `409 BucketAlreadyOwnedByYou` for every authorized requester. Requests that
/// lack `s3:CreateBucket` still fail authorization before the duplicate check.
#[tokio::test]
async fn test_create_bucket_duplicate_outside_default_region_reports_owned_by_you() -> Result<(), Box<dyn Error + Send + Sync>>
{
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec!["--region", "eu-west-1"]).await?;
let region = "eu-west-1";
let root = Client::from_conf(
env.create_s3_client()
.config()
.to_builder()
.region(Region::new(region))
.build(),
);
admin_create_user(&env, "duplicate-eu-iam", "duplicate-eu-iam-secret").await?;
admin_attach_user_policy_via(
AdminTransport::Signed,
&env.url,
&env.access_key,
&env.secret_key,
"readwrite",
"duplicate-eu-iam",
)
.await?;
let iam = Client::from_conf(
build_test_s3_config(&env.url, "duplicate-eu-iam", "duplicate-eu-iam-secret", None, "duplicate-create")
.to_builder()
.region(Region::new(region))
.build(),
);
admin_create_user(&env, "duplicate-eu-readonly", "duplicate-eu-readonly-secret").await?;
admin_attach_user_policy_via(
AdminTransport::Signed,
&env.url,
&env.access_key,
&env.secret_key,
"readonly",
"duplicate-eu-readonly",
)
.await?;
let readonly = Client::from_conf(
build_test_s3_config(
&env.url,
"duplicate-eu-readonly",
"duplicate-eu-readonly-secret",
None,
"duplicate-create",
)
.to_builder()
.region(Region::new(region))
.build(),
);
for (client, bucket) in [(&root, "duplicate-eu-root"), (&iam, "duplicate-eu-iam")] {
client
.create_bucket()
.bucket(bucket)
.send()
.await
.unwrap_or_else(|error| panic!("{bucket} must be created: {error:?}"));
// Both the creator and the other authenticated identity own the namespace.
for creator in [&root, &iam] {
let error = creator
.create_bucket()
.bucket(bucket)
.send()
.await
.expect_err("a duplicate create outside us-east-1 must report ownership");
assert_eq!(
error.as_service_error().and_then(ProvideErrorMetadata::code),
Some("BucketAlreadyOwnedByYou"),
"{bucket}"
);
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(409), "{bucket}");
}
}
let denied = readonly
.create_bucket()
.bucket("duplicate-eu-root")
.send()
.await
.expect_err("a requester without s3:CreateBucket must be denied before the duplicate check");
assert_eq!(denied.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
Ok(())
}
}
+32 -38
View File
@@ -1267,19 +1267,22 @@ fn build_list_objects_v2_metadata_output(
}
}
fn create_bucket_exists_response(is_owner: bool, region: &str) -> S3Result<S3Response<CreateBucketOutput>> {
if is_owner && region == RUSTFS_REGION {
// AWS preserves the legacy idempotent response for buckets recreated in
// us-east-1 (the default RustFS region).
/// Answer a `CreateBucket` for a bucket that already exists.
///
/// A deployment owns one bucket namespace, so every requester that passed
/// authorization belongs to the same owner as the bucket's creator; there is no
/// second account that could trigger `BucketAlreadyExists`. That leaves the AWS
/// account-owner semantics: the default region returns the legacy idempotent
/// `200 OK`, any other region returns `BucketAlreadyOwnedByYou`.
fn create_bucket_exists_response(region: &str) -> S3Result<S3Response<CreateBucketOutput>> {
if region == RUSTFS_REGION {
return Ok(S3Response::new(CreateBucketOutput::default()));
}
let error_code = if is_owner {
S3ErrorCode::BucketAlreadyOwnedByYou
} else {
S3ErrorCode::BucketAlreadyExists
};
Err(S3Error::with_message(error_code, "The requested bucket already exists."))
Err(S3Error::with_message(
S3ErrorCode::BucketAlreadyOwnedByYou,
"The requested bucket already exists.",
))
}
fn resolve_notification_region(global_region: Option<Region>, request_region: Option<Region>) -> String {
@@ -1460,14 +1463,12 @@ impl DefaultBucketUsecase {
current_object_store_handle_for_context(self.context.as_deref())
}
fn create_bucket_exists_result(&self, requester_is_owner: bool) -> S3Result<S3Response<CreateBucketOutput>> {
// Match AWS CreateBucket semantics: an owned bucket is idempotent in
// us-east-1 and returns BucketAlreadyOwnedByYou elsewhere.
fn create_bucket_exists_result(&self) -> S3Result<S3Response<CreateBucketOutput>> {
let region = self
.global_region()
.map(|region| region.to_string())
.unwrap_or_else(|| RUSTFS_REGION.to_string());
create_bucket_exists_response(requester_is_owner, &region)
create_bucket_exists_response(&region)
}
#[instrument(
@@ -1497,7 +1498,7 @@ impl DefaultBucketUsecase {
// An existing bucket needs no mutation. Answer before the mutation queue and the
// bucket's exclusive locks, which wait for every in-flight object write.
if let Ok(req_info) = req_info_ref(&req)
if req_info_ref(&req).is_ok()
&& let Some(store) = self.object_store()
&& store
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
@@ -1505,7 +1506,7 @@ impl DefaultBucketUsecase {
.is_ok()
{
let helper = OperationHelper::new(&req, EventName::BucketCreated, S3Operation::CreateBucket);
let result = self.create_bucket_exists_result(req_info.is_owner);
let result = self.create_bucket_exists_result();
let _ = helper.complete(&result);
return result;
}
@@ -1523,12 +1524,9 @@ impl DefaultBucketUsecase {
) -> S3Result<S3Response<CreateBucketOutput>> {
let helper = OperationHelper::new(&req, EventName::BucketCreated, S3Operation::CreateBucket);
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
let requester_is_owner = match req_info_ref(&req) {
Ok(r) => r.is_owner,
Err(_) => {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Missing request info".to_string()));
}
};
if req_info_ref(&req).is_err() {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Missing request info".to_string()));
}
let CreateBucketInput {
bucket,
object_lock_enabled_for_bucket,
@@ -1588,7 +1586,7 @@ impl DefaultBucketUsecase {
.await;
}
Err(StorageError::BucketExists(_)) => {
let result = self.create_bucket_exists_result(requester_is_owner);
let result = self.create_bucket_exists_result();
let _ = helper.complete(&result);
return result;
}
@@ -4477,20 +4475,14 @@ mod tests {
}
#[test]
fn create_bucket_exists_response_returns_ok_for_owner() {
let response = create_bucket_exists_response(true, RUSTFS_REGION).expect("owner recreate in us-east-1 should succeed");
fn create_bucket_exists_response_returns_ok_in_default_region() {
let response = create_bucket_exists_response(RUSTFS_REGION).expect("recreate in us-east-1 should succeed");
assert_eq!(response.output.location, None);
}
#[test]
fn create_bucket_exists_response_returns_bucket_already_exists_for_non_owner() {
let err = create_bucket_exists_response(false, RUSTFS_REGION).expect_err("non-owner recreate should fail");
assert_eq!(err.code(), &S3ErrorCode::BucketAlreadyExists);
}
#[test]
fn create_bucket_exists_response_returns_owned_by_you_outside_us_east_1() {
let err = create_bucket_exists_response(true, "eu-west-1").expect_err("owner recreate outside us-east-1 should fail");
fn create_bucket_exists_response_returns_owned_by_you_outside_default_region() {
let err = create_bucket_exists_response("eu-west-1").expect_err("recreate outside us-east-1 should report ownership");
assert_eq!(err.code(), &S3ErrorCode::BucketAlreadyOwnedByYou);
}
@@ -4553,9 +4545,11 @@ mod tests {
.expect("hold the bucket lifecycle lock");
let usecase = DefaultBucketUsecase::with_context(Some(context));
for (is_owner, location, expected_error) in [
// The requester's owner flag must not change the duplicate response:
// one deployment has a single bucket owner.
for (requester_is_owner, location, expected_error) in [
(true, None, None),
(false, None, Some(S3ErrorCode::BucketAlreadyExists)),
(false, None, None),
(true, Some("ap-south-1"), Some(S3ErrorCode::InvalidLocationConstraint)),
] {
let input = CreateBucketInput {
@@ -4570,7 +4564,7 @@ mod tests {
input,
Method::PUT,
ReqInfo {
is_owner,
is_owner: requester_is_owner,
..Default::default()
},
);
@@ -4578,9 +4572,9 @@ mod tests {
.await
.expect("CreateBucket on an existing bucket must not wait for the bucket lifecycle lock");
match expected_error {
Some(code) => assert_eq!(result.expect_err("duplicate or invalid request must fail").code(), &code),
Some(code) => assert_eq!(result.expect_err("invalid request must fail").code(), &code),
None => {
result.expect("the owner recreating a bucket in us-east-1 gets the idempotent success");
result.expect("recreating a bucket in us-east-1 returns the idempotent success");
}
}
}
+8
View File
@@ -315,3 +315,11 @@ test_lifecycle_cloud_transition_target_by_bucket
test_lifecycle_cloud_transition_target_by_bucket_multiple_buckets
test_list_object_versions_restore_status
test_list_objects_restore_status
# Require per-account bucket ownership across distinct S3 accounts
# A RustFS deployment owns one bucket namespace and records no bucket owner, so every
# authorized requester is the bucket's owner: a duplicate CreateBucket returns the
# legacy 200 in the default region and BucketAlreadyOwnedByYou elsewhere, never
# BucketAlreadyExists for a second account. The deployment-level behavior is covered by
# crates/e2e_test/src/create_bucket_region_test.rs.
test_bucket_create_exists_nonowner
-1
View File
@@ -205,7 +205,6 @@ test_put_public_block
test_block_public_policy
test_block_public_policy_with_principal
test_block_public_restrict_public_buckets
test_bucket_create_exists_nonowner
test_get_public_block_deny_bucket_policy
test_get_undefined_public_block