diff --git a/crates/e2e_test/src/group_delete_test.rs b/crates/e2e_test/src/group_delete_test.rs new file mode 100644 index 000000000..80a698b27 --- /dev/null +++ b/crates/e2e_test/src/group_delete_test.rs @@ -0,0 +1,76 @@ +// Copyright 2026 RustFS Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::common::{RustFSTestEnvironment, awscurl_delete, awscurl_get, awscurl_put, init_logging}; +use serial_test::serial; +use tracing::info; + +/// Test that deleting a group with members fails, and deleting an empty group succeeds. +#[tokio::test(flavor = "multi_thread")] +#[serial] +#[ignore = "requires awscurl and spawns a real RustFS server"] +async fn test_delete_group_requires_empty_membership() -> Result<(), Box> { + init_logging(); + + let mut env = RustFSTestEnvironment::new().await?; + env.start_rustfs_server(vec![]).await?; + + // 1. Create a user + let add_user_url = format!("{}/rustfs/admin/v3/add-user?accessKey=testuser1", env.url); + let user_body = serde_json::json!({ + "secretKey": "testuser1secret", + "status": "enabled" + }); + awscurl_put(&add_user_url, &user_body.to_string(), &env.access_key, &env.secret_key).await?; + info!("Created testuser1"); + + // 2. Create a group with testuser1 as a member + let update_members_url = format!("{}/rustfs/admin/v3/update-group-members", env.url); + let add_member_body = serde_json::json!({ + "group": "testgroup", + "members": ["testuser1"], + "isRemove": false, + "groupStatus": "enabled" + }); + awscurl_put(&update_members_url, &add_member_body.to_string(), &env.access_key, &env.secret_key).await?; + info!("Added testuser1 to testgroup"); + + // 3. Attempt to delete the group while it still has members — should fail + let delete_group_url = format!("{}/rustfs/admin/v3/group/testgroup", env.url); + let delete_result = awscurl_delete(&delete_group_url, &env.access_key, &env.secret_key).await; + assert!(delete_result.is_err(), "deleting a non-empty group should fail"); + info!("Delete of non-empty group correctly rejected"); + + // 4. Remove the member from the group + let remove_member_body = serde_json::json!({ + "group": "testgroup", + "members": ["testuser1"], + "isRemove": true, + "groupStatus": "enabled" + }); + awscurl_put(&update_members_url, &remove_member_body.to_string(), &env.access_key, &env.secret_key).await?; + info!("Removed testuser1 from testgroup"); + + // 5. Delete the now-empty group — should succeed + awscurl_delete(&delete_group_url, &env.access_key, &env.secret_key).await?; + info!("Deleted empty testgroup successfully"); + + // 6. Verify the group no longer exists + let get_group_url = format!("{}/rustfs/admin/v3/group?group=testgroup", env.url); + let get_result = awscurl_get(&get_group_url, &env.access_key, &env.secret_key).await; + assert!(get_result.is_err(), "group should no longer exist after deletion"); + info!("Confirmed testgroup no longer exists"); + + Ok(()) +} diff --git a/crates/e2e_test/src/lib.rs b/crates/e2e_test/src/lib.rs index a619201d7..0fc6e0ccb 100644 --- a/crates/e2e_test/src/lib.rs +++ b/crates/e2e_test/src/lib.rs @@ -75,3 +75,7 @@ mod cluster_concurrency_test; // PutObject / MultipartUpload with checksum (Content-MD5, x-amz-checksum-*) #[cfg(test)] mod checksum_upload_test; + +// Group deletion tests +#[cfg(test)] +mod group_delete_test; diff --git a/rustfs/src/admin/handlers/group.rs b/rustfs/src/admin/handlers/group.rs index 721dce6f2..f9b2044b6 100644 --- a/rustfs/src/admin/handlers/group.rs +++ b/rustfs/src/admin/handlers/group.rs @@ -51,6 +51,12 @@ pub fn register_group_management_route(r: &mut S3Router) -> std: AdminOperation(&GetGroup {}), )?; + r.insert( + Method::DELETE, + format!("{}{}", ADMIN_PREFIX, "/v3/group/{group}").as_str(), + AdminOperation(&DeleteGroup {}), + )?; + r.insert( Method::PUT, format!("{}{}", ADMIN_PREFIX, "/v3/set-group-status").as_str(), @@ -159,6 +165,89 @@ impl Operation for GetGroup { } } +/// Deletes an empty user group. +/// +/// # Arguments +/// * `group` - The name of the group to delete +/// +/// # Returns +/// - `200 OK` - Group deleted successfully +/// - `400 Bad Request` - Group name missing or invalid +/// - `401 Unauthorized` - Insufficient permissions +/// - `404 Not Found` - Group does not exist +/// - `409 Conflict` - Group contains members and cannot be deleted +/// - `500 Internal Server Error` - Server-side error +/// +/// # Example +/// ``` +/// DELETE /rustfs/admin/v3/group/developers +/// ``` +pub struct DeleteGroup {} +#[async_trait::async_trait] +impl Operation for DeleteGroup { + async fn call(&self, req: S3Request, params: Params<'_, '_>) -> S3Result> { + warn!("handle DeleteGroup"); + + let Some(input_cred) = req.credentials else { + return Err(s3_error!(InvalidRequest, "get cred failed")); + }; + + let (cred, owner) = + check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; + + validate_admin_request( + &req.headers, + &cred, + owner, + false, + vec![Action::AdminAction(AdminAction::RemoveUserFromGroupAdminAction)], + req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), + ) + .await?; + + let group = params + .get("group") + .ok_or_else(|| s3_error!(InvalidArgument, "missing group name in request"))? + .trim(); + + // Validate the group name format + if group.is_empty() || group.len() > 256 { + return Err(s3_error!(InvalidArgument, "invalid group name")); + } + + // Sanity check the group name + if group.contains(['/', '\\', '\0']) { + return Err(s3_error!(InvalidArgument, "group name contains invalid characters")); + } + + let Ok(iam_store) = rustfs_iam::get() else { return Err(s3_error!(InternalError, "iam not init")) }; + + iam_store.remove_users_from_group(group, vec![]).await.map_err(|e| { + warn!("delete group failed, e: {:?}", e); + match e { + rustfs_iam::error::Error::GroupNotEmpty => { + s3_error!(InvalidRequest, "group is not empty") + } + rustfs_iam::error::Error::InvalidArgument => { + s3_error!(InvalidArgument, "{e}") + } + _ => { + if is_err_no_such_group(&e) { + s3_error!(NoSuchKey, "group '{group}' does not exist") + } else { + s3_error!(InternalError, "{e}") + } + } + } + })?; + + let mut header = HeaderMap::new(); + header.insert(CONTENT_TYPE, "application/json".parse().unwrap()); + header.insert(CONTENT_LENGTH, "0".parse().unwrap()); + Ok(S3Response::with_headers((StatusCode::OK, Body::empty()), header)) + } +} + pub struct SetGroupStatus {} #[async_trait::async_trait] impl Operation for SetGroupStatus { diff --git a/rustfs/src/admin/route_registration_test.rs b/rustfs/src/admin/route_registration_test.rs index 59ece9a94..fdc89fb2b 100644 --- a/rustfs/src/admin/route_registration_test.rs +++ b/rustfs/src/admin/route_registration_test.rs @@ -64,6 +64,7 @@ fn test_register_routes_cover_representative_admin_paths() { assert_route(&router, Method::PUT, &admin_path("/v3/add-user")); assert_route(&router, Method::PUT, &admin_path("/v3/set-user-status")); assert_route(&router, Method::GET, &admin_path("/v3/groups")); + assert_route(&router, Method::DELETE, &admin_path("/v3/group/test-group")); assert_route(&router, Method::PUT, &admin_path("/v3/update-group-members")); assert_route(&router, Method::PUT, &admin_path("/v3/add-service-accounts")); assert_route(&router, Method::GET, &admin_path("/v3/export-iam"));