This is an automated email from the ASF dual-hosted git repository.

roryqi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new 587207ee3f [#12287] feat(server): Add bulk group access-control APIs 
(#12511)
587207ee3f is described below

commit 587207ee3f4e86bb7280e1e671b3a8e9f471bfad
Author: jarred0214 <[email protected]>
AuthorDate: Sat Aug 29 13:00:16 2026 +0800

    [#12287] feat(server): Add bulk group access-control APIs (#12511)
    
    ### What changes were proposed in this pull request?
    
    This PR adds best-effort bulk group access-control APIs as the second
    part of #12287.
    
    Changes include:
    - Add bulk group add request/response DTOs.
    - Add a core `GroupAdd` bulk item model.
    - Add `AccessControlDispatcher#addGroups` and `#removeGroups`.
    - Implement bulk group add/remove logic in `AccessControlManager` with
    item-level results.
    - Dispatch existing per-group add/remove pre, success, and failure
    events for bulk group operations.
    - Add REST APIs for bulk group add/remove.
    - Add OpenAPI definitions and access-control documentation for bulk
    group APIs.
    - Add tests for best-effort behavior, owner-group removal validation,
    request size validation, and listener events.
    
    ### Why are the changes needed?
    
    #12287 tracks best-effort bulk operations for access-control entities
    under a metalake. The first PR added the shared bulk foundation and bulk
    user APIs. This PR continues the epic by adding bulk group APIs using
    the same approach.
    
    Part of #12287.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes.
    
    New REST APIs:
    - `POST /api/bulk/metalakes/{metalake}/groups/add`
    - `POST /api/bulk/metalakes/{metalake}/groups/remove`
    
    The APIs use the existing `gravitino.server.bulk.maxItems` limit
    introduced by the bulk foundation PR.
---
 .../authorization/BaseRestApiAuthorizationIT.java  |   8 +
 .../test/authorization/GroupAuthorizationIT.java   |  92 ++++++++++-
 .../test/authorization/UserAuthorizationIT.java    |  82 +++++++++-
 .../dto/requests/BulkGroupAddRequest.java          |  63 ++++++++
 .../gravitino/dto/responses/BulkGroupResponse.java |  72 +++++++++
 .../authorization/AccessControlDispatcher.java     |  27 ++++
 .../authorization/AccessControlManager.java        |  68 ++++++++
 .../gravitino/bulk/{UserAdd.java => GroupAdd.java} |  38 ++---
 .../java/org/apache/gravitino/bulk/UserAdd.java    |   3 +-
 .../hook/AccessControlHookDispatcher.java          |  14 ++
 .../listener/AccessControlEventDispatcher.java     |  65 ++++++++
 .../authorization/TestAccessControlManager.java    |  50 ++++++
 .../listener/api/event/TestGroupEvent.java         |  79 ++++++++++
 docs/open-api/bulk.yaml                            | 173 +++++++++++++++++++++
 docs/open-api/openapi.yaml                         |   6 +
 docs/security/access-control.md                    |  43 ++++-
 .../gravitino/server/web/rest/BulkOperations.java  | 104 +++++++++++++
 .../server/web/rest/TestBulkOperations.java        |  93 +++++++++++
 18 files changed, 1045 insertions(+), 35 deletions(-)

diff --git 
a/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/BaseRestApiAuthorizationIT.java
 
b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/BaseRestApiAuthorizationIT.java
index 488d3c4208..6db4794d59 100644
--- 
a/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/BaseRestApiAuthorizationIT.java
+++ 
b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/BaseRestApiAuthorizationIT.java
@@ -20,11 +20,13 @@ package 
org.apache.gravitino.client.integration.test.authorization;
 import com.google.common.collect.ImmutableMap;
 import java.io.File;
 import java.io.IOException;
+import java.lang.reflect.Method;
 import java.nio.file.Files;
 import java.util.HashMap;
 import org.apache.gravitino.Configs;
 import org.apache.gravitino.client.GravitinoAdminClient;
 import org.apache.gravitino.client.GravitinoMetalake;
+import org.apache.gravitino.client.RESTClient;
 import org.apache.gravitino.integration.test.util.BaseIT;
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.BeforeAll;
@@ -73,6 +75,12 @@ public class BaseRestApiAuthorizationIT extends BaseIT {
     customConfigs.put(Configs.SERVICE_ADMINS.getKey(), USER);
   }
 
+  protected RESTClient restClient(GravitinoAdminClient client) throws 
Exception {
+    Method restClientMethod = 
client.getClass().getSuperclass().getDeclaredMethod("restClient");
+    restClientMethod.setAccessible(true);
+    return (RESTClient) restClientMethod.invoke(client);
+  }
+
   @AfterAll
   @Override
   public void stopIntegrationTest() throws IOException, InterruptedException {
diff --git 
a/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/GroupAuthorizationIT.java
 
b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/GroupAuthorizationIT.java
index a2790eed74..ce50d4f124 100644
--- 
a/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/GroupAuthorizationIT.java
+++ 
b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/GroupAuthorizationIT.java
@@ -18,6 +18,7 @@
 package org.apache.gravitino.client.integration.test.authorization;
 
 import static org.junit.Assert.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertEquals;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Maps;
@@ -27,9 +28,19 @@ import java.util.HashMap;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.MetadataObjects;
 import org.apache.gravitino.authorization.Privileges;
+import org.apache.gravitino.client.ErrorHandlers;
+import org.apache.gravitino.client.GravitinoAdminClient;
 import org.apache.gravitino.client.GravitinoMetalake;
+import org.apache.gravitino.client.RESTClient;
 import org.apache.gravitino.dto.MetalakeDTO;
+import org.apache.gravitino.dto.requests.BulkGroupAddRequest;
+import org.apache.gravitino.dto.requests.BulkRemoveRequest;
+import org.apache.gravitino.dto.requests.GroupAddRequest;
+import org.apache.gravitino.dto.responses.BulkGroupResponse;
+import org.apache.gravitino.dto.responses.BulkRemoveResponse;
 import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.gravitino.rest.RESTUtils;
+import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.MethodOrderer;
 import org.junit.jupiter.api.Order;
 import org.junit.jupiter.api.Tag;
@@ -55,6 +66,54 @@ public class GroupAuthorizationIT extends 
BaseRestApiAuthorizationIT {
 
   @Test
   @Order(2)
+  public void testBulkGroupInterfaces() throws Exception {
+    String bulkGroupManager = "bulk_group_manager";
+    String bulkGroup1 = "bulk_group_it_1";
+    String bulkGroup2 = "bulk_group_it_2";
+    GravitinoMetalake gravitinoMetalake = client.loadMetalake(METALAKE);
+    gravitinoMetalake.addUser(bulkGroupManager);
+
+    assertThrows(
+        ForbiddenException.class,
+        () ->
+            bulkAddGroups(
+                restClient(normalUserClient),
+                new BulkGroupAddRequest(new GroupAddRequest[] {new 
GroupAddRequest(bulkGroup1)})));
+    assertThrows(
+        ForbiddenException.class,
+        () ->
+            bulkRemoveGroups(
+                restClient(normalUserClient), new BulkRemoveRequest(new 
String[] {bulkGroup1})));
+
+    gravitinoMetalake.createRole("bulk_group_role", new HashMap<>(), 
Collections.emptyList());
+    gravitinoMetalake.grantPrivilegesToRole(
+        "bulk_group_role",
+        MetadataObjects.of(null, METALAKE, MetadataObject.Type.METALAKE),
+        ImmutableList.of(Privileges.ManageGroups.allow()));
+    gravitinoMetalake.grantRolesToUser(ImmutableList.of("bulk_group_role"), 
bulkGroupManager);
+
+    GravitinoAdminClient bulkGroupManagerClient = 
getClientByUser(bulkGroupManager);
+    BulkGroupResponse addResponse =
+        bulkAddGroups(
+            restClient(bulkGroupManagerClient),
+            new BulkGroupAddRequest(
+                new GroupAddRequest[] {
+                  new GroupAddRequest(bulkGroup1), new 
GroupAddRequest(bulkGroup2)
+                }));
+    assertEquals(2, addResponse.getGroups().length);
+    assertEquals(0, addResponse.getErrors().length);
+
+    BulkRemoveResponse removeResponse =
+        bulkRemoveGroups(
+            restClient(bulkGroupManagerClient),
+            new BulkRemoveRequest(new String[] {bulkGroup1, bulkGroup2}));
+    Assertions.assertArrayEquals(new String[] {bulkGroup1, bulkGroup2}, 
removeResponse.getNames());
+    assertEquals(0, removeResponse.getErrors().length);
+    gravitinoMetalake.removeUser(bulkGroupManager);
+  }
+
+  @Test
+  @Order(3)
   public void testRemoveGroup() {
     assertThrows(
         "Current user access metadata {testMetalake}",
@@ -76,7 +135,7 @@ public class GroupAuthorizationIT extends 
BaseRestApiAuthorizationIT {
   }
 
   @Test
-  @Order(3)
+  @Order(4)
   public void testListGroupsWithNonExistentMetalake() throws Exception {
     // Test that listGroups with @AuthorizationExpression returns 403 Forbidden
     // when the metalake doesn't exist, instead of 404 response
@@ -118,4 +177,35 @@ public class GroupAuthorizationIT extends 
BaseRestApiAuthorizationIT {
     // Test listGroupNames - should return 403 ForbiddenException
     assertThrows(ForbiddenException.class, 
nonExistentMetalakeObj::listGroupNames);
   }
+
+  private GravitinoAdminClient getClientByUser(String username) {
+    GravitinoAdminClient client =
+        
GravitinoAdminClient.builder(serverUri).withSimpleAuth(username).build();
+    closer.register(client);
+    return client;
+  }
+
+  private BulkGroupResponse bulkAddGroups(RESTClient restClient, 
BulkGroupAddRequest request) {
+    BulkGroupResponse response =
+        restClient.post(
+            String.format("api/bulk/metalakes/%s/groups/add", 
RESTUtils.encodeString(METALAKE)),
+            request,
+            BulkGroupResponse.class,
+            Collections.emptyMap(),
+            ErrorHandlers.groupErrorHandler());
+    response.validate();
+    return response;
+  }
+
+  private BulkRemoveResponse bulkRemoveGroups(RESTClient restClient, 
BulkRemoveRequest request) {
+    BulkRemoveResponse response =
+        restClient.post(
+            String.format("api/bulk/metalakes/%s/groups/remove", 
RESTUtils.encodeString(METALAKE)),
+            request,
+            BulkRemoveResponse.class,
+            Collections.emptyMap(),
+            ErrorHandlers.groupErrorHandler());
+    response.validate();
+    return response;
+  }
 }
diff --git 
a/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/UserAuthorizationIT.java
 
b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/UserAuthorizationIT.java
index f0b92daccc..57bbc54ef2 100644
--- 
a/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/UserAuthorizationIT.java
+++ 
b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/UserAuthorizationIT.java
@@ -31,10 +31,18 @@ import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.MetadataObjects;
 import org.apache.gravitino.authorization.Privileges;
 import org.apache.gravitino.authorization.User;
+import org.apache.gravitino.client.ErrorHandlers;
 import org.apache.gravitino.client.GravitinoAdminClient;
 import org.apache.gravitino.client.GravitinoMetalake;
+import org.apache.gravitino.client.RESTClient;
 import org.apache.gravitino.dto.MetalakeDTO;
+import org.apache.gravitino.dto.requests.BulkRemoveRequest;
+import org.apache.gravitino.dto.requests.BulkUserAddRequest;
+import org.apache.gravitino.dto.requests.UserAddRequest;
+import org.apache.gravitino.dto.responses.BulkRemoveResponse;
+import org.apache.gravitino.dto.responses.BulkUserResponse;
 import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.gravitino.rest.RESTUtils;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.MethodOrderer;
 import org.junit.jupiter.api.Order;
@@ -97,6 +105,54 @@ public class UserAuthorizationIT extends 
BaseRestApiAuthorizationIT {
 
   @Test
   @Order(3)
+  public void testBulkUserInterfaces() throws Exception {
+    String bulkUserManager = "bulk_user_manager";
+    String bulkUser1 = "bulk_user_it_1";
+    String bulkUser2 = "bulk_user_it_2";
+    GravitinoMetalake gravitinoMetalake = client.loadMetalake(METALAKE);
+    gravitinoMetalake.addUser(bulkUserManager);
+
+    assertThrows(
+        ForbiddenException.class,
+        () ->
+            bulkAddUsers(
+                restClient(normalUserClient),
+                new BulkUserAddRequest(new UserAddRequest[] {new 
UserAddRequest(bulkUser1)})));
+    assertThrows(
+        ForbiddenException.class,
+        () ->
+            bulkRemoveUsers(
+                restClient(normalUserClient), new BulkRemoveRequest(new 
String[] {bulkUser1})));
+
+    gravitinoMetalake.createRole("bulk_user_role", new HashMap<>(), 
Collections.emptyList());
+    gravitinoMetalake.grantPrivilegesToRole(
+        "bulk_user_role",
+        MetadataObjects.of(null, METALAKE, MetadataObject.Type.METALAKE),
+        ImmutableList.of(Privileges.ManageUsers.allow()));
+    gravitinoMetalake.grantRolesToUser(ImmutableList.of("bulk_user_role"), 
bulkUserManager);
+
+    GravitinoAdminClient bulkUserManagerClient = 
getClientByUser(bulkUserManager);
+    BulkUserResponse addResponse =
+        bulkAddUsers(
+            restClient(bulkUserManagerClient),
+            new BulkUserAddRequest(
+                new UserAddRequest[] {
+                  new UserAddRequest(bulkUser1), new UserAddRequest(bulkUser2)
+                }));
+    assertEquals(2, addResponse.getUsers().length);
+    assertEquals(0, addResponse.getErrors().length);
+
+    BulkRemoveResponse removeResponse =
+        bulkRemoveUsers(
+            restClient(bulkUserManagerClient),
+            new BulkRemoveRequest(new String[] {bulkUser1, bulkUser2}));
+    Assertions.assertArrayEquals(new String[] {bulkUser1, bulkUser2}, 
removeResponse.getNames());
+    assertEquals(0, removeResponse.getErrors().length);
+    gravitinoMetalake.removeUser(bulkUserManager);
+  }
+
+  @Test
+  @Order(4)
   public void testRemoveUser() {
     GravitinoAdminClient user1Client = getClientByUser("user1");
     assertThrows(
@@ -130,7 +186,7 @@ public class UserAuthorizationIT extends 
BaseRestApiAuthorizationIT {
   }
 
   @Test
-  @Order(4)
+  @Order(5)
   public void testListUsersWithNonExistentMetalake() throws Exception {
     // Test that listUsers with @AuthorizationExpression returns 403 Forbidden
     // when the metalake doesn't exist, instead of 404 response
@@ -188,4 +244,28 @@ public class UserAuthorizationIT extends 
BaseRestApiAuthorizationIT {
     closer.register(client);
     return client;
   }
+
+  private BulkUserResponse bulkAddUsers(RESTClient restClient, 
BulkUserAddRequest request) {
+    BulkUserResponse response =
+        restClient.post(
+            String.format("api/bulk/metalakes/%s/users/add", 
RESTUtils.encodeString(METALAKE)),
+            request,
+            BulkUserResponse.class,
+            Collections.emptyMap(),
+            ErrorHandlers.userErrorHandler());
+    response.validate();
+    return response;
+  }
+
+  private BulkRemoveResponse bulkRemoveUsers(RESTClient restClient, 
BulkRemoveRequest request) {
+    BulkRemoveResponse response =
+        restClient.post(
+            String.format("api/bulk/metalakes/%s/users/remove", 
RESTUtils.encodeString(METALAKE)),
+            request,
+            BulkRemoveResponse.class,
+            Collections.emptyMap(),
+            ErrorHandlers.userErrorHandler());
+    response.validate();
+    return response;
+  }
 }
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/requests/BulkGroupAddRequest.java
 
b/common/src/main/java/org/apache/gravitino/dto/requests/BulkGroupAddRequest.java
new file mode 100644
index 0000000000..3049231c54
--- /dev/null
+++ 
b/common/src/main/java/org/apache/gravitino/dto/requests/BulkGroupAddRequest.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.gravitino.dto.requests;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.google.common.base.Preconditions;
+import java.util.Arrays;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.ToString;
+import org.apache.gravitino.rest.RESTRequest;
+
+/** Represents a request to add groups in bulk. */
+@Getter
+@EqualsAndHashCode
+@ToString
+public class BulkGroupAddRequest implements RESTRequest {
+
+  @JsonProperty("groups")
+  private final GroupAddRequest[] groups;
+
+  /**
+   * Creates a new BulkGroupAddRequest.
+   *
+   * @param groups The group add requests.
+   */
+  public BulkGroupAddRequest(GroupAddRequest[] groups) {
+    this.groups = groups;
+  }
+
+  /** Default constructor for BulkGroupAddRequest. (Used for Jackson 
deserialization.) */
+  public BulkGroupAddRequest() {
+    this(null);
+  }
+
+  @Override
+  public void validate() throws IllegalArgumentException {
+    Preconditions.checkArgument(
+        groups != null && groups.length > 0, "\"groups\" must not be empty");
+    Arrays.stream(groups)
+        .forEach(
+            group -> {
+              Preconditions.checkArgument(group != null, "group must not be 
null");
+              group.validate();
+            });
+  }
+}
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/responses/BulkGroupResponse.java
 
b/common/src/main/java/org/apache/gravitino/dto/responses/BulkGroupResponse.java
new file mode 100644
index 0000000000..49fc87a820
--- /dev/null
+++ 
b/common/src/main/java/org/apache/gravitino/dto/responses/BulkGroupResponse.java
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.gravitino.dto.responses;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.google.common.base.Preconditions;
+import java.util.Arrays;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.ToString;
+import org.apache.gravitino.dto.authorization.GroupDTO;
+
+/** Represents a bulk group response. */
+@Getter
+@ToString
+@EqualsAndHashCode(callSuper = true)
+public class BulkGroupResponse extends BaseResponse {
+
+  @JsonProperty("groups")
+  private final GroupDTO[] groups;
+
+  @JsonProperty("errors")
+  private final BulkError[] errors;
+
+  @JsonProperty("summary")
+  private final BulkSummary summary;
+
+  /**
+   * Creates a new BulkGroupResponse.
+   *
+   * @param groups The successfully added groups.
+   * @param errors The item-level errors.
+   * @param summary The summary counts.
+   */
+  public BulkGroupResponse(GroupDTO[] groups, BulkError[] errors, BulkSummary 
summary) {
+    super(0);
+    this.groups = groups;
+    this.errors = errors;
+    this.summary = summary;
+  }
+
+  /** Default constructor for BulkGroupResponse. (Used for Jackson 
deserialization.) */
+  public BulkGroupResponse() {
+    this(null, null, null);
+  }
+
+  @Override
+  public void validate() throws IllegalArgumentException {
+    super.validate();
+    Preconditions.checkArgument(groups != null, "groups must not be null");
+    Preconditions.checkArgument(errors != null, "errors must not be null");
+    Preconditions.checkArgument(summary != null, "summary must not be null");
+    Arrays.stream(errors).forEach(BulkError::validate);
+    summary.validate();
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/authorization/AccessControlDispatcher.java
 
b/core/src/main/java/org/apache/gravitino/authorization/AccessControlDispatcher.java
index e91af8c6e9..dfab6eddd5 100644
--- 
a/core/src/main/java/org/apache/gravitino/authorization/AccessControlDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/AccessControlDispatcher.java
@@ -24,6 +24,7 @@ import java.util.Optional;
 import java.util.Set;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.bulk.BulkItemResult;
+import org.apache.gravitino.bulk.GroupAdd;
 import org.apache.gravitino.bulk.UserAdd;
 import org.apache.gravitino.exceptions.GroupAlreadyExistsException;
 import org.apache.gravitino.exceptions.IllegalRoleException;
@@ -257,6 +258,18 @@ public interface AccessControlDispatcher {
   Group addGroup(String metalake, String group, String externalId)
       throws GroupAlreadyExistsException, NoSuchMetalakeException;
 
+  /**
+   * Adds groups in bulk.
+   *
+   * @param metalake The Metalake of the Groups.
+   * @param groups The Groups to add.
+   * @return The item-level bulk results.
+   * @throws NoSuchMetalakeException If the Metalake with the given name does 
not exist.
+   * @throws RuntimeException If adding the Groups encounters storage issues.
+   */
+  List<BulkItemResult<Group>> addGroups(String metalake, List<GroupAdd> groups)
+      throws NoSuchMetalakeException;
+
   /**
    * Removes a Group.
    *
@@ -269,6 +282,20 @@ public interface AccessControlDispatcher {
    */
   boolean removeGroup(String metalake, String group) throws 
NoSuchMetalakeException;
 
+  /**
+   * Removes Groups in bulk.
+   *
+   * @param metalake The Metalake of the Groups.
+   * @param groups The names of the Groups.
+   * @param metalakeOwner The Metalake owner.
+   * @return The item-level bulk results.
+   * @throws NoSuchMetalakeException If the Metalake with the given name does 
not exist.
+   * @throws RuntimeException If removing the Groups encounters storage issues.
+   */
+  List<BulkItemResult<String>> removeGroups(
+      String metalake, List<String> groups, Optional<Owner> metalakeOwner)
+      throws NoSuchMetalakeException;
+
   /**
    * Removes a Group by external identifier.
    *
diff --git 
a/core/src/main/java/org/apache/gravitino/authorization/AccessControlManager.java
 
b/core/src/main/java/org/apache/gravitino/authorization/AccessControlManager.java
index 05b44dc4c2..72f5022a6e 100644
--- 
a/core/src/main/java/org/apache/gravitino/authorization/AccessControlManager.java
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/AccessControlManager.java
@@ -29,6 +29,7 @@ import org.apache.gravitino.EntityStore;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.bulk.BulkItemResult;
+import org.apache.gravitino.bulk.GroupAdd;
 import org.apache.gravitino.bulk.UserAdd;
 import org.apache.gravitino.exceptions.GroupAlreadyExistsException;
 import org.apache.gravitino.exceptions.IllegalRoleException;
@@ -253,6 +254,31 @@ public class AccessControlManager implements 
AccessControlDispatcher {
         () -> userGroupExternalManager.addGroup(metalake, group, externalId));
   }
 
+  @Override
+  public List<BulkItemResult<Group>> addGroups(String metalake, List<GroupAdd> 
groups)
+      throws NoSuchMetalakeException {
+    return TreeLockUtils.doWithTreeLock(
+        
NameIdentifier.of(AuthorizationUtils.ofGroupNamespace(metalake).levels()),
+        LockType.WRITE,
+        () -> {
+          List<BulkItemResult<Group>> results = 
Lists.newArrayListWithCapacity(groups.size());
+          for (int index = 0; index < groups.size(); index++) {
+            GroupAdd group = groups.get(index);
+            try {
+              Group addedGroup =
+                  group.hasExternalId()
+                      ? userGroupExternalManager.addGroup(
+                          metalake, group.name(), group.externalId())
+                      : userGroupManager.addGroup(metalake, group.name());
+              results.add(BulkItemResult.success(index, group.name(), 
addedGroup));
+            } catch (Exception e) {
+              results.add(BulkItemResult.failure(index, group.name(), e));
+            }
+          }
+          return results;
+        });
+  }
+
   @Override
   public boolean removeGroup(String metalake, String group) throws 
NoSuchMetalakeException {
     return TreeLockUtils.doWithTreeLock(
@@ -261,6 +287,35 @@ public class AccessControlManager implements 
AccessControlDispatcher {
         () -> userGroupManager.removeGroup(metalake, group));
   }
 
+  @Override
+  public List<BulkItemResult<String>> removeGroups(
+      String metalake, List<String> groups, Optional<Owner> metalakeOwner)
+      throws NoSuchMetalakeException {
+    return TreeLockUtils.doWithTreeLock(
+        
NameIdentifier.of(AuthorizationUtils.ofGroupNamespace(metalake).levels()),
+        LockType.WRITE,
+        () -> {
+          List<BulkItemResult<String>> results = 
Lists.newArrayListWithCapacity(groups.size());
+          for (int index = 0; index < groups.size(); index++) {
+            String group = groups.get(index);
+            try {
+              ensureNotMetalakeOwnerGroup(metalakeOwner, metalake, group);
+              boolean removed = userGroupManager.removeGroup(metalake, group);
+              if (!removed) {
+                results.add(
+                    BulkItemResult.failure(
+                        index, group, new NoSuchGroupException("Group does not 
exist: %s", group)));
+                continue;
+              }
+              results.add(BulkItemResult.success(index, group));
+            } catch (Exception e) {
+              results.add(BulkItemResult.failure(index, group, e));
+            }
+          }
+          return results;
+        });
+  }
+
   @Override
   public boolean removeGroupByExternalId(String metalake, String externalId)
       throws NoSuchMetalakeException {
@@ -475,4 +530,17 @@ public class AccessControlManager implements 
AccessControlDispatcher {
           }
         });
   }
+
+  private void ensureNotMetalakeOwnerGroup(
+      Optional<Owner> metalakeOwner, String metalake, String group) {
+    metalakeOwner.ifPresent(
+        owner -> {
+          if (owner.type() == Owner.Type.GROUP && owner.name().equals(group)) {
+            throw new IllegalArgumentException(
+                String.format(
+                    "Cannot remove group %s from metalake %s because the group 
is the owner of the metalake.",
+                    group, metalake));
+          }
+        });
+  }
 }
diff --git a/core/src/main/java/org/apache/gravitino/bulk/UserAdd.java 
b/core/src/main/java/org/apache/gravitino/bulk/GroupAdd.java
similarity index 57%
copy from core/src/main/java/org/apache/gravitino/bulk/UserAdd.java
copy to core/src/main/java/org/apache/gravitino/bulk/GroupAdd.java
index d62fd46a4c..d4e76a68d2 100644
--- a/core/src/main/java/org/apache/gravitino/bulk/UserAdd.java
+++ b/core/src/main/java/org/apache/gravitino/bulk/GroupAdd.java
@@ -3,8 +3,8 @@
  * or more contributor license agreements.  See the NOTICE file
  * distributed with this work for additional information
  * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
+ * to you 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
@@ -19,31 +19,29 @@
 package org.apache.gravitino.bulk;
 
 import javax.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
 
-/** Represents one user to add in a bulk operation. */
-public final class UserAdd {
+/** Represents one group to add in a bulk operation. */
+public final class GroupAdd {
 
   private final String name;
   @Nullable private final String externalId;
-  @Nullable private final Boolean enabled;
 
   /**
-   * Creates a user add item.
+   * Creates a group add item.
    *
-   * @param name The user name.
+   * @param name The group name.
    * @param externalId The external identifier, or null if unset.
-   * @param enabled Whether the user is enabled, or null to use the default 
value.
    */
-  public UserAdd(String name, @Nullable String externalId, @Nullable Boolean 
enabled) {
+  public GroupAdd(String name, @Nullable String externalId) {
     this.name = name;
     this.externalId = externalId;
-    this.enabled = enabled;
   }
 
   /**
-   * Returns the user name.
+   * Returns the group name.
    *
-   * @return The user name.
+   * @return The group name.
    */
   public String name() {
     return name;
@@ -60,21 +58,11 @@ public final class UserAdd {
   }
 
   /**
-   * Returns whether the user has an external identifier.
+   * Returns whether the group has an external identifier.
    *
-   * @return True if the user has an external identifier, otherwise false.
+   * @return True if the group has a non-blank external identifier, otherwise 
false.
    */
   public boolean hasExternalId() {
-    return externalId != null && !externalId.isEmpty();
-  }
-
-  /**
-   * Returns whether the user is enabled.
-   *
-   * @return Whether the user is enabled, or null to use the default value.
-   */
-  @Nullable
-  public Boolean enabled() {
-    return enabled;
+    return StringUtils.isNotBlank(externalId);
   }
 }
diff --git a/core/src/main/java/org/apache/gravitino/bulk/UserAdd.java 
b/core/src/main/java/org/apache/gravitino/bulk/UserAdd.java
index d62fd46a4c..8c33b4abb7 100644
--- a/core/src/main/java/org/apache/gravitino/bulk/UserAdd.java
+++ b/core/src/main/java/org/apache/gravitino/bulk/UserAdd.java
@@ -19,6 +19,7 @@
 package org.apache.gravitino.bulk;
 
 import javax.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
 
 /** Represents one user to add in a bulk operation. */
 public final class UserAdd {
@@ -65,7 +66,7 @@ public final class UserAdd {
    * @return True if the user has an external identifier, otherwise false.
    */
   public boolean hasExternalId() {
-    return externalId != null && !externalId.isEmpty();
+    return StringUtils.isNotBlank(externalId);
   }
 
   /**
diff --git 
a/core/src/main/java/org/apache/gravitino/hook/AccessControlHookDispatcher.java 
b/core/src/main/java/org/apache/gravitino/hook/AccessControlHookDispatcher.java
index 570626eaee..6dfa084e99 100644
--- 
a/core/src/main/java/org/apache/gravitino/hook/AccessControlHookDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/hook/AccessControlHookDispatcher.java
@@ -39,6 +39,7 @@ import org.apache.gravitino.authorization.SecurableObject;
 import org.apache.gravitino.authorization.User;
 import org.apache.gravitino.authorization.UserChange;
 import org.apache.gravitino.bulk.BulkItemResult;
+import org.apache.gravitino.bulk.GroupAdd;
 import org.apache.gravitino.bulk.UserAdd;
 import org.apache.gravitino.exceptions.GroupAlreadyExistsException;
 import org.apache.gravitino.exceptions.IllegalRoleException;
@@ -167,11 +168,24 @@ public class AccessControlHookDispatcher implements 
AccessControlDispatcher {
     return dispatcher.addGroup(metalake, group, externalId);
   }
 
+  @Override
+  public List<BulkItemResult<Group>> addGroups(String metalake, List<GroupAdd> 
groups)
+      throws NoSuchMetalakeException {
+    return dispatcher.addGroups(metalake, groups);
+  }
+
   @Override
   public boolean removeGroup(String metalake, String group) throws 
NoSuchMetalakeException {
     return dispatcher.removeGroup(metalake, group);
   }
 
+  @Override
+  public List<BulkItemResult<String>> removeGroups(
+      String metalake, List<String> groups, Optional<Owner> metalakeOwner)
+      throws NoSuchMetalakeException {
+    return dispatcher.removeGroups(metalake, groups, metalakeOwner);
+  }
+
   @Override
   public boolean removeGroupByExternalId(String metalake, String externalId)
       throws NoSuchMetalakeException {
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/AccessControlEventDispatcher.java
 
b/core/src/main/java/org/apache/gravitino/listener/AccessControlEventDispatcher.java
index 95e4aa7125..26bc9fc358 100644
--- 
a/core/src/main/java/org/apache/gravitino/listener/AccessControlEventDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/listener/AccessControlEventDispatcher.java
@@ -35,6 +35,7 @@ import org.apache.gravitino.authorization.SecurableObject;
 import org.apache.gravitino.authorization.User;
 import org.apache.gravitino.authorization.UserChange;
 import org.apache.gravitino.bulk.BulkItemResult;
+import org.apache.gravitino.bulk.GroupAdd;
 import org.apache.gravitino.bulk.UserAdd;
 import org.apache.gravitino.exceptions.GroupAlreadyExistsException;
 import org.apache.gravitino.exceptions.IllegalRoleException;
@@ -490,6 +491,27 @@ public class AccessControlEventDispatcher implements 
AccessControlDispatcher {
     }
   }
 
+  /** {@inheritDoc} */
+  @Override
+  public List<BulkItemResult<Group>> addGroups(String metalake, List<GroupAdd> 
groups)
+      throws NoSuchMetalakeException {
+    String initiator = PrincipalUtils.getCurrentUserName();
+    groups.forEach(
+        group -> eventBus.dispatchEvent(new AddGroupPreEvent(initiator, 
metalake, group.name())));
+
+    try {
+      List<BulkItemResult<Group>> results = dispatcher.addGroups(metalake, 
groups);
+      results.forEach(result -> dispatchAddGroupResultEvent(initiator, 
metalake, result));
+      return results;
+    } catch (Exception e) {
+      groups.forEach(
+          group ->
+              eventBus.dispatchEvent(
+                  new AddGroupFailureEvent(initiator, metalake, e, 
group.name())));
+      throw e;
+    }
+  }
+
   /** {@inheritDoc} */
   @Override
   public boolean removeGroup(String metalake, String group) throws 
NoSuchMetalakeException {
@@ -507,6 +529,28 @@ public class AccessControlEventDispatcher implements 
AccessControlDispatcher {
     }
   }
 
+  /** {@inheritDoc} */
+  @Override
+  public List<BulkItemResult<String>> removeGroups(
+      String metalake, List<String> groups, Optional<Owner> metalakeOwner)
+      throws NoSuchMetalakeException {
+    String initiator = PrincipalUtils.getCurrentUserName();
+    groups.forEach(
+        group -> eventBus.dispatchEvent(new RemoveGroupPreEvent(initiator, 
metalake, group)));
+
+    try {
+      List<BulkItemResult<String>> results =
+          dispatcher.removeGroups(metalake, groups, metalakeOwner);
+      results.forEach(result -> dispatchRemoveGroupResultEvent(initiator, 
metalake, result));
+      return results;
+    } catch (Exception e) {
+      groups.forEach(
+          group ->
+              eventBus.dispatchEvent(new RemoveGroupFailureEvent(initiator, 
metalake, e, group)));
+      throw e;
+    }
+  }
+
   /** {@inheritDoc} */
   @Override
   public boolean removeGroupByExternalId(String metalake, String externalId)
@@ -965,4 +1009,25 @@ public class AccessControlEventDispatcher implements 
AccessControlDispatcher {
           new RemoveUserFailureEvent(initiator, metalake, 
result.error().get(), result.name()));
     }
   }
+
+  private void dispatchAddGroupResultEvent(
+      String initiator, String metalake, BulkItemResult<Group> result) {
+    if (result.succeeded()) {
+      eventBus.dispatchEvent(
+          new AddGroupEvent(initiator, metalake, new 
GroupInfo(result.value().get())));
+    } else {
+      eventBus.dispatchEvent(
+          new AddGroupFailureEvent(initiator, metalake, result.error().get(), 
result.name()));
+    }
+  }
+
+  private void dispatchRemoveGroupResultEvent(
+      String initiator, String metalake, BulkItemResult<String> result) {
+    if (result.succeeded()) {
+      eventBus.dispatchEvent(new RemoveGroupEvent(initiator, metalake, 
result.name(), true));
+    } else {
+      eventBus.dispatchEvent(
+          new RemoveGroupFailureEvent(initiator, metalake, 
result.error().get(), result.name()));
+    }
+  }
 }
diff --git 
a/core/src/test/java/org/apache/gravitino/authorization/TestAccessControlManager.java
 
b/core/src/test/java/org/apache/gravitino/authorization/TestAccessControlManager.java
index e664d00dee..14e14565ef 100644
--- 
a/core/src/test/java/org/apache/gravitino/authorization/TestAccessControlManager.java
+++ 
b/core/src/test/java/org/apache/gravitino/authorization/TestAccessControlManager.java
@@ -65,6 +65,7 @@ import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.StringIdentifier;
 import org.apache.gravitino.bulk.BulkItemResult;
+import org.apache.gravitino.bulk.GroupAdd;
 import org.apache.gravitino.bulk.UserAdd;
 import org.apache.gravitino.catalog.CatalogManager;
 import org.apache.gravitino.connector.BaseCatalog;
@@ -300,6 +301,55 @@ public class TestAccessControlManager {
     Assertions.assertTrue(results.get(2).error().get() instanceof 
IllegalArgumentException);
   }
 
+  @Test
+  public void testBulkAddGroups() {
+    List<BulkItemResult<Group>> results =
+        accessControlManager.addGroups(
+            METALAKE,
+            Lists.newArrayList(
+                new GroupAdd("bulk_group_1", "bulk-group-ext-1"),
+                new GroupAdd("bulk_group_2", null),
+                new GroupAdd("bulk_group_1", null)));
+
+    Assertions.assertEquals(3, results.size());
+    Assertions.assertTrue(results.get(0).succeeded());
+    Assertions.assertEquals("bulk_group_1", 
results.get(0).value().get().name());
+    Assertions.assertEquals("bulk-group-ext-1", 
results.get(0).value().get().externalId());
+    Assertions.assertTrue(results.get(1).succeeded());
+    Assertions.assertFalse(results.get(2).succeeded());
+    Assertions.assertTrue(results.get(2).error().get() instanceof 
GroupAlreadyExistsException);
+  }
+
+  @Test
+  public void testBulkRemoveGroups() {
+    accessControlManager.addGroup(METALAKE, "bulk_remove_group");
+
+    List<BulkItemResult<String>> results =
+        accessControlManager.removeGroups(
+            METALAKE,
+            Lists.newArrayList("bulk_remove_group", "missing_bulk_group", 
"metalake_owner_group"),
+            Optional.of(
+                new Owner() {
+                  @Override
+                  public String name() {
+                    return "metalake_owner_group";
+                  }
+
+                  @Override
+                  public Type type() {
+                    return Type.GROUP;
+                  }
+                }));
+
+    Assertions.assertEquals(3, results.size());
+    Assertions.assertTrue(results.get(0).succeeded());
+    Assertions.assertEquals("bulk_remove_group", results.get(0).name());
+    Assertions.assertFalse(results.get(1).succeeded());
+    Assertions.assertTrue(results.get(1).error().get() instanceof 
NoSuchGroupException);
+    Assertions.assertFalse(results.get(2).succeeded());
+    Assertions.assertTrue(results.get(2).error().get() instanceof 
IllegalArgumentException);
+  }
+
   @Test
   public void testListUsers() {
     accessControlManager.addUser("metalake_list", "testList1");
diff --git 
a/core/src/test/java/org/apache/gravitino/listener/api/event/TestGroupEvent.java
 
b/core/src/test/java/org/apache/gravitino/listener/api/event/TestGroupEvent.java
index 5bd7a70662..601928a94f 100644
--- 
a/core/src/test/java/org/apache/gravitino/listener/api/event/TestGroupEvent.java
+++ 
b/core/src/test/java/org/apache/gravitino/listener/api/event/TestGroupEvent.java
@@ -36,6 +36,8 @@ import org.apache.gravitino.authorization.AuthorizationUtils;
 import org.apache.gravitino.authorization.Group;
 import org.apache.gravitino.authorization.GroupChange;
 import org.apache.gravitino.authorization.PagedResult;
+import org.apache.gravitino.bulk.BulkItemResult;
+import org.apache.gravitino.bulk.GroupAdd;
 import org.apache.gravitino.exceptions.GravitinoRuntimeException;
 import org.apache.gravitino.exceptions.NoSuchGroupException;
 import org.apache.gravitino.exceptions.NoSuchMetalakeException;
@@ -642,6 +644,39 @@ public class TestGroupEvent {
     Assertions.assertEquals(OperationType.ADD_GROUP, event.operationType());
   }
 
+  @Test
+  void testBulkAddGroupsDispatchesPerGroupEvents() {
+    dummyEventListener.clear();
+
+    dispatcher.addGroups(
+        METALAKE,
+        Arrays.asList(new GroupAdd(groupName, GROUP_EXT_ID), new 
GroupAdd(otherGroupName, null)));
+
+    Assertions.assertEquals(2, dummyEventListener.getPreEvents().size());
+    PreEvent firstPreEvent = dummyEventListener.getPreEvents().get(0);
+    Assertions.assertEquals(AddGroupPreEvent.class, firstPreEvent.getClass());
+    Assertions.assertEquals(
+        NameIdentifierUtil.ofGroup(METALAKE, groupName), 
firstPreEvent.identifier());
+    Assertions.assertEquals(groupName, ((AddGroupPreEvent) 
firstPreEvent).groupName());
+    PreEvent secondPreEvent = dummyEventListener.getPreEvents().get(1);
+    Assertions.assertEquals(AddGroupPreEvent.class, secondPreEvent.getClass());
+    Assertions.assertEquals(
+        NameIdentifierUtil.ofGroup(METALAKE, otherGroupName), 
secondPreEvent.identifier());
+    Assertions.assertEquals(otherGroupName, ((AddGroupPreEvent) 
secondPreEvent).groupName());
+
+    Assertions.assertEquals(2, dummyEventListener.getPostEvents().size());
+    Event firstEvent = dummyEventListener.getPostEvents().get(0);
+    Assertions.assertEquals(AddGroupEvent.class, firstEvent.getClass());
+    Assertions.assertEquals(OperationStatus.SUCCESS, 
firstEvent.operationStatus());
+    validateGroup(((AddGroupEvent) firstEvent).addedGroupInfo(), group);
+    Event secondEvent = dummyEventListener.getPostEvents().get(1);
+    Assertions.assertEquals(AddGroupFailureEvent.class, 
secondEvent.getClass());
+    Assertions.assertEquals(OperationStatus.FAILURE, 
secondEvent.operationStatus());
+    Assertions.assertEquals(
+        NameIdentifierUtil.ofGroup(METALAKE, otherGroupName), 
secondEvent.identifier());
+    Assertions.assertEquals(otherGroupName, ((AddGroupFailureEvent) 
secondEvent).groupName());
+  }
+
   @Test
   void testGetGroupByExternalIdEvent() {
     dispatcher.getGroupByExternalId(METALAKE, GROUP_EXT_ID);
@@ -669,6 +704,38 @@ public class TestGroupEvent {
     Assertions.assertEquals(OperationType.REMOVE_GROUP_BY_EXTERNAL_ID, 
event.operationType());
   }
 
+  @Test
+  void testBulkRemoveGroupsDispatchesPerGroupEvents() {
+    dummyEventListener.clear();
+
+    dispatcher.removeGroups(METALAKE, Arrays.asList(groupName, 
inExistGroupName), Optional.empty());
+
+    Assertions.assertEquals(2, dummyEventListener.getPreEvents().size());
+    PreEvent firstPreEvent = dummyEventListener.getPreEvents().get(0);
+    Assertions.assertEquals(RemoveGroupPreEvent.class, 
firstPreEvent.getClass());
+    Assertions.assertEquals(
+        NameIdentifierUtil.ofGroup(METALAKE, groupName), 
firstPreEvent.identifier());
+    Assertions.assertEquals(groupName, ((RemoveGroupPreEvent) 
firstPreEvent).groupName());
+    PreEvent secondPreEvent = dummyEventListener.getPreEvents().get(1);
+    Assertions.assertEquals(RemoveGroupPreEvent.class, 
secondPreEvent.getClass());
+    Assertions.assertEquals(
+        NameIdentifierUtil.ofGroup(METALAKE, inExistGroupName), 
secondPreEvent.identifier());
+    Assertions.assertEquals(inExistGroupName, ((RemoveGroupPreEvent) 
secondPreEvent).groupName());
+
+    Assertions.assertEquals(2, dummyEventListener.getPostEvents().size());
+    Event firstEvent = dummyEventListener.getPostEvents().get(0);
+    Assertions.assertEquals(RemoveGroupEvent.class, firstEvent.getClass());
+    Assertions.assertEquals(OperationStatus.SUCCESS, 
firstEvent.operationStatus());
+    Assertions.assertEquals(groupName, ((RemoveGroupEvent) 
firstEvent).removedGroupName());
+    Assertions.assertTrue(((RemoveGroupEvent) firstEvent).isExists());
+    Event secondEvent = dummyEventListener.getPostEvents().get(1);
+    Assertions.assertEquals(RemoveGroupFailureEvent.class, 
secondEvent.getClass());
+    Assertions.assertEquals(OperationStatus.FAILURE, 
secondEvent.operationStatus());
+    Assertions.assertEquals(
+        NameIdentifierUtil.ofGroup(METALAKE, inExistGroupName), 
secondEvent.identifier());
+    Assertions.assertEquals(inExistGroupName, ((RemoveGroupFailureEvent) 
secondEvent).groupName());
+  }
+
   @Test
   void testGetGroupByExternalIdFailureEvent() {
     Assertions.assertThrowsExactly(
@@ -770,9 +837,21 @@ public class TestGroupEvent {
     when(dispatcher.addGroup(METALAKE, groupName)).thenReturn(group);
     when(dispatcher.addGroup(METALAKE, otherGroupName)).thenReturn(otherGroup);
     when(dispatcher.addGroup(METALAKE, groupName, 
GROUP_EXT_ID)).thenReturn(externalIdGroup);
+    when(dispatcher.addGroups(eq(METALAKE), any()))
+        .thenReturn(
+            Arrays.asList(
+                BulkItemResult.success(0, groupName, group),
+                BulkItemResult.failure(
+                    1, otherGroupName, new GravitinoRuntimeException("Failed 
to add group"))));
 
     when(dispatcher.removeGroup(METALAKE, groupName)).thenReturn(true);
     when(dispatcher.removeGroup(METALAKE, inExistGroupName)).thenReturn(false);
+    when(dispatcher.removeGroups(eq(METALAKE), any(), any()))
+        .thenReturn(
+            Arrays.asList(
+                BulkItemResult.success(0, groupName),
+                BulkItemResult.failure(
+                    1, inExistGroupName, new NoSuchGroupException("group not 
found"))));
     when(dispatcher.removeGroupByExternalId(METALAKE, 
GROUP_EXT_ID)).thenReturn(true);
     when(dispatcher.removeGroupById(METALAKE, GROUP_ID)).thenReturn(true);
 
diff --git a/docs/open-api/bulk.yaml b/docs/open-api/bulk.yaml
index 7377d39d21..ab35a5ed28 100644
--- a/docs/open-api/bulk.yaml
+++ b/docs/open-api/bulk.yaml
@@ -103,6 +103,90 @@ paths:
         "5xx":
           $ref: "./openapi.yaml#/components/responses/ServerErrorResponse"
 
+  /bulk/metalakes/{metalake}/groups/add:
+    parameters:
+      - $ref: "./openapi.yaml#/components/parameters/metalake"
+
+    post:
+      tags:
+        - access control
+      summary: Add groups in bulk
+      operationId: bulkAddGroups
+      description: Adds groups in best-effort mode. Failed items are returned 
in the top-level errors array. The maximum request size is controlled by 
`gravitino.server.bulk.maxItems`, which defaults to 100.
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: "#/components/schemas/BulkGroupAddRequest"
+            examples:
+              BulkGroupAddRequest:
+                $ref: "#/components/examples/BulkGroupAddRequest"
+      responses:
+        "200":
+          description: Returns successfully added groups and item-level errors
+          content:
+            application/vnd.gravitino.v1+json:
+              schema:
+                $ref: "#/components/schemas/BulkGroupResponse"
+              examples:
+                BulkGroupResponse:
+                  $ref: "#/components/examples/BulkGroupResponse"
+        "400":
+          $ref: "./openapi.yaml#/components/responses/BadRequestErrorResponse"
+        "404":
+          description: Not Found - The specified metalake does not exist
+          content:
+            application/vnd.gravitino.v1+json:
+              schema:
+                $ref: "./openapi.yaml#/components/schemas/ErrorModel"
+              examples:
+                NoSuchMetalakeException:
+                  $ref: 
"./metalakes.yaml#/components/examples/NoSuchMetalakeException"
+        "5xx":
+          $ref: "./openapi.yaml#/components/responses/ServerErrorResponse"
+
+  /bulk/metalakes/{metalake}/groups/remove:
+    parameters:
+      - $ref: "./openapi.yaml#/components/parameters/metalake"
+
+    post:
+      tags:
+        - access control
+      summary: Remove groups in bulk
+      operationId: bulkRemoveGroups
+      description: Removes groups in best-effort mode. Failed items are 
returned in the top-level errors array. Group names in the same request must be 
unique. The maximum request size is controlled by 
`gravitino.server.bulk.maxItems`, which defaults to 100.
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: "#/components/schemas/BulkRemoveRequest"
+            examples:
+              BulkRemoveRequest:
+                $ref: "#/components/examples/BulkRemoveGroupsRequest"
+      responses:
+        "200":
+          description: Returns successfully removed names and item-level errors
+          content:
+            application/vnd.gravitino.v1+json:
+              schema:
+                $ref: "#/components/schemas/BulkRemoveResponse"
+              examples:
+                BulkRemoveResponse:
+                  $ref: "#/components/examples/BulkRemoveGroupsResponse"
+        "400":
+          $ref: "./openapi.yaml#/components/responses/BadRequestErrorResponse"
+        "404":
+          description: Not Found - The specified metalake does not exist
+          content:
+            application/vnd.gravitino.v1+json:
+              schema:
+                $ref: "./openapi.yaml#/components/schemas/ErrorModel"
+              examples:
+                NoSuchMetalakeException:
+                  $ref: 
"./metalakes.yaml#/components/examples/NoSuchMetalakeException"
+        "5xx":
+          $ref: "./openapi.yaml#/components/responses/ServerErrorResponse"
+
 components:
 
   schemas:
@@ -118,6 +202,18 @@ components:
           items:
             $ref: "./users.yaml#/components/schemas/UserAddRequest"
 
+    BulkGroupAddRequest:
+      type: object
+      required:
+        - groups
+      properties:
+        groups:
+          type: array
+          minItems: 1
+          description: The maximum length is controlled by 
`gravitino.server.bulk.maxItems`, which defaults to 100.
+          items:
+            $ref: "./groups.yaml#/components/schemas/GroupAddRequest"
+
     BulkRemoveRequest:
       type: object
       required:
@@ -195,6 +291,28 @@ components:
         summary:
           $ref: "#/components/schemas/BulkSummary"
 
+    BulkGroupResponse:
+      type: object
+      properties:
+        code:
+          type: integer
+          format: int32
+          description: Status code of the response
+          enum:
+            - 0
+        groups:
+          type: array
+          description: Successfully added groups
+          items:
+            $ref: "./groups.yaml#/components/schemas/Group"
+        errors:
+          type: array
+          description: Item-level errors
+          items:
+            $ref: "#/components/schemas/BulkError"
+        summary:
+          $ref: "#/components/schemas/BulkSummary"
+
     BulkRemoveResponse:
       type: object
       properties:
@@ -231,6 +349,19 @@ components:
         "names": ["alice", "bob", "ghost"]
       }
 
+    BulkGroupAddRequest:
+      value: {
+        "groups": [
+          {"name": "engineers", "externalId": "ext-engineers"},
+          {"name": "analysts"}
+        ]
+      }
+
+    BulkRemoveGroupsRequest:
+      value: {
+        "names": ["engineers", "analysts", "ghost"]
+      }
+
     BulkUserResponse:
       value: {
         "code": 0,
@@ -273,3 +404,45 @@ components:
         ],
         "summary": {"total": 3, "succeeded": 2, "failed": 1}
       }
+
+    BulkGroupResponse:
+      value: {
+        "code": 0,
+        "groups": [
+          {
+            "name": "engineers",
+            "externalId": "ext-engineers",
+            "roles": [],
+            "audit": {
+              "creator": "gravitino",
+              "createTime": "2026-07-28T10:00:00Z"
+            }
+          }
+        ],
+        "errors": [
+          {
+            "index": 1,
+            "name": "analysts",
+            "code": 1004,
+            "type": "GroupAlreadyExistsException",
+            "message": "Group already exists: analysts"
+          }
+        ],
+        "summary": {"total": 2, "succeeded": 1, "failed": 1}
+      }
+
+    BulkRemoveGroupsResponse:
+      value: {
+        "code": 0,
+        "names": ["engineers", "analysts"],
+        "errors": [
+          {
+            "index": 2,
+            "name": "ghost",
+            "code": 1003,
+            "type": "NoSuchGroupException",
+            "message": "Group does not exist: ghost"
+          }
+        ],
+        "summary": {"total": 3, "succeeded": 2, "failed": 1}
+      }
diff --git a/docs/open-api/openapi.yaml b/docs/open-api/openapi.yaml
index 2b3643d613..727187a8f9 100644
--- a/docs/open-api/openapi.yaml
+++ b/docs/open-api/openapi.yaml
@@ -213,6 +213,12 @@ paths:
   /bulk/metalakes/{metalake}/users/remove:
     $ref: "./bulk.yaml#/paths/~1bulk~1metalakes~1%7Bmetalake%7D~1users~1remove"
 
+  /bulk/metalakes/{metalake}/groups/add:
+    $ref: "./bulk.yaml#/paths/~1bulk~1metalakes~1%7Bmetalake%7D~1groups~1add"
+
+  /bulk/metalakes/{metalake}/groups/remove:
+    $ref: 
"./bulk.yaml#/paths/~1bulk~1metalakes~1%7Bmetalake%7D~1groups~1remove"
+
   /metalakes/{metalake}/owners/{metadataObjectType}/{metadataObjectFullName}:
     $ref: 
"./owners.yaml#/paths/~1metalakes~1%7Bmetalake%7D~1owners~1%7BmetadataObjectType%7D~1%7BmetadataObjectFullName%7D"
 
diff --git a/docs/security/access-control.md b/docs/security/access-control.md
index dc6ddad78c..7d6b29e7b5 100755
--- a/docs/security/access-control.md
+++ b/docs/security/access-control.md
@@ -271,14 +271,16 @@ object: the owner of the table or view, plus 
`CREATE_TABLE` or `CREATE_VIEW` on
 | Job template | `REGISTER_JOB_TEMPLATE` | `USE_JOB_TEMPLATE`                  
   | Owner           | Run a job: `RUN_JOB` and `USE_JOB_TEMPLATE` |
 | Job          |                         | Owner                               
   | Owner           |                                           |
 
-Bulk user access-control APIs use the same privileges as the matching 
single-user operations. These
-bulk operations are authorized once before processing the request. Bulk user 
requests report
-item-level failures in `errors`.
+Bulk access-control APIs use the same privileges as the matching single-entity 
operations. These
+bulk operations are authorized once before processing the request. Bulk 
requests report item-level
+failures in `errors`.
 
-| API                                                | Required privilege      
                  |
-|----------------------------------------------------|-------------------------------------------|
-| `POST /api/bulk/metalakes/{metalake}/users/add`    | `OWNER` of the metalake 
or `MANAGE_USERS` |
-| `POST /api/bulk/metalakes/{metalake}/users/remove` | `OWNER` of the metalake 
or `MANAGE_USERS` |
+| API                                                 | Required privilege     
                    |
+|-----------------------------------------------------|--------------------------------------------|
+| `POST /api/bulk/metalakes/{metalake}/users/add`     | `OWNER` of the 
metalake or `MANAGE_USERS`  |
+| `POST /api/bulk/metalakes/{metalake}/users/remove`  | `OWNER` of the 
metalake or `MANAGE_USERS`  |
+| `POST /api/bulk/metalakes/{metalake}/groups/add`    | `OWNER` of the 
metalake or `MANAGE_GROUPS` |
+| `POST /api/bulk/metalakes/{metalake}/groups/remove` | `OWNER` of the 
metalake or `MANAGE_GROUPS` |
 
 For example, add users in bulk:
 
@@ -307,6 +309,33 @@ curl -X POST 
"http://localhost:8090/api/bulk/metalakes/{metalake}/users/remove";
 }'
 ```
 
+For example, add groups in bulk:
+
+```shell
+curl -X POST "http://localhost:8090/api/bulk/metalakes/{metalake}/groups/add"; \
+  -H "Authorization: Bearer $MANAGER_TOKEN" \
+  -H "Accept: application/vnd.gravitino.v1+json" \
+  -H "Content-Type: application/json" \
+  -d '{
+  "groups": [
+    {"name": "analysts"},
+    {"name": "developers", "externalId": "[email protected]"}
+  ]
+}'
+```
+
+Remove groups in bulk:
+
+```shell
+curl -X POST 
"http://localhost:8090/api/bulk/metalakes/{metalake}/groups/remove"; \
+  -H "Authorization: Bearer $MANAGER_TOKEN" \
+  -H "Accept: application/vnd.gravitino.v1+json" \
+  -H "Content-Type: application/json" \
+  -d '{
+  "names": ["analysts", "developers"]
+}'
+```
+
 Granting or revoking a privilege on an object takes `MANAGE_GRANTS` on that 
object or an ancestor.
 Granting or revoking a role, and overriding a role's privileges, takes 
`MANAGE_GRANTS` on the
 metalake. Setting an owner takes ownership.
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/rest/BulkOperations.java 
b/server/src/main/java/org/apache/gravitino/server/web/rest/BulkOperations.java
index 0cd73dd5fe..d8e73d1750 100644
--- 
a/server/src/main/java/org/apache/gravitino/server/web/rest/BulkOperations.java
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/rest/BulkOperations.java
@@ -36,16 +36,21 @@ import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.MetadataObjects;
 import org.apache.gravitino.authorization.AccessControlDispatcher;
+import org.apache.gravitino.authorization.Group;
 import org.apache.gravitino.authorization.Owner;
 import org.apache.gravitino.authorization.OwnerDispatcher;
 import org.apache.gravitino.authorization.User;
 import org.apache.gravitino.bulk.BulkItemResult;
 import org.apache.gravitino.bulk.BulkManager;
+import org.apache.gravitino.bulk.GroupAdd;
 import org.apache.gravitino.bulk.UserAdd;
+import org.apache.gravitino.dto.authorization.GroupDTO;
 import org.apache.gravitino.dto.authorization.UserDTO;
+import org.apache.gravitino.dto.requests.BulkGroupAddRequest;
 import org.apache.gravitino.dto.requests.BulkRemoveRequest;
 import org.apache.gravitino.dto.requests.BulkUserAddRequest;
 import org.apache.gravitino.dto.responses.BulkError;
+import org.apache.gravitino.dto.responses.BulkGroupResponse;
 import org.apache.gravitino.dto.responses.BulkRemoveResponse;
 import org.apache.gravitino.dto.responses.BulkSummary;
 import org.apache.gravitino.dto.responses.BulkUserResponse;
@@ -63,6 +68,7 @@ import org.apache.gravitino.server.web.Utils;
 public class BulkOperations {
 
   private static final String USERS_FIELD_NAME = "users";
+  private static final String GROUPS_FIELD_NAME = "groups";
   private static final String NAMES_FIELD_NAME = "names";
 
   private final BulkManager bulkManager;
@@ -178,4 +184,102 @@ public class BulkOperations {
       return ExceptionHandlers.handleUserException(OperationType.REMOVE, "", 
metalake, e);
     }
   }
+
+  /**
+   * Adds groups in bulk.
+   *
+   * @param metalake The metalake name.
+   * @param request The bulk group add request.
+   * @return The bulk group response.
+   */
+  @POST
+  @Path("groups/add")
+  @Produces("application/vnd.gravitino.v1+json")
+  @Timed(name = "bulk-add-group." + MetricNames.HTTP_PROCESS_DURATION, 
absolute = true)
+  @ResponseMetered(name = "bulk-add-group", absolute = true)
+  @AuthorizationExpression(expression = "METALAKE::OWNER || 
METALAKE::MANAGE_GROUPS")
+  public Response addGroups(
+      @PathParam("metalake") @AuthorizationMetadata(type = 
Entity.EntityType.METALAKE)
+          String metalake,
+      BulkGroupAddRequest request) {
+    try {
+      return Utils.doAs(
+          httpRequest,
+          () -> {
+            request.validate();
+            bulkManager.checkBulkSize(GROUPS_FIELD_NAME, 
request.getGroups().length);
+            MetalakeManager.checkMetalakeInUse(metalake);
+            List<BulkItemResult<Group>> results =
+                accessControlDispatcher.addGroups(
+                    metalake,
+                    Arrays.stream(request.getGroups())
+                        .map(group -> new GroupAdd(group.getName(), 
group.getExternalId()))
+                        .collect(Collectors.toList()));
+            GroupDTO[] groups =
+                results.stream()
+                    .filter(BulkItemResult::succeeded)
+                    .map(result -> DTOConverters.toDTO(result.value().get()))
+                    .toArray(GroupDTO[]::new);
+            BulkError[] errors =
+                results.stream()
+                    .filter(result -> !result.succeeded())
+                    .map(bulkManager::toBulkError)
+                    .toArray(BulkError[]::new);
+            return Utils.ok(
+                new BulkGroupResponse(
+                    groups, errors, new BulkSummary(results.size(), 
groups.length, errors.length)));
+          });
+    } catch (Exception e) {
+      return ExceptionHandlers.handleGroupException(OperationType.ADD, "", 
metalake, e);
+    }
+  }
+
+  /**
+   * Removes groups in bulk.
+   *
+   * @param metalake The metalake name.
+   * @param request The bulk remove request.
+   * @return The bulk remove response.
+   */
+  @POST
+  @Path("groups/remove")
+  @Produces("application/vnd.gravitino.v1+json")
+  @Timed(name = "bulk-remove-group." + MetricNames.HTTP_PROCESS_DURATION, 
absolute = true)
+  @ResponseMetered(name = "bulk-remove-group", absolute = true)
+  @AuthorizationExpression(expression = "METALAKE::OWNER || 
METALAKE::MANAGE_GROUPS")
+  public Response removeGroups(
+      @PathParam("metalake") @AuthorizationMetadata(type = 
Entity.EntityType.METALAKE)
+          String metalake,
+      BulkRemoveRequest request) {
+    try {
+      return Utils.doAs(
+          httpRequest,
+          () -> {
+            request.validate();
+            bulkManager.checkBulkSize(NAMES_FIELD_NAME, 
request.getNames().length);
+            MetalakeManager.checkMetalakeInUse(metalake);
+            Optional<Owner> metalakeOwner =
+                ownerDispatcher.getOwner(
+                    metalake, MetadataObjects.of(null, metalake, 
MetadataObject.Type.METALAKE));
+            List<BulkItemResult<String>> results =
+                accessControlDispatcher.removeGroups(
+                    metalake, Arrays.asList(request.getNames()), 
metalakeOwner);
+            String[] names =
+                results.stream()
+                    .filter(BulkItemResult::succeeded)
+                    .map(BulkItemResult::name)
+                    .toArray(String[]::new);
+            BulkError[] errors =
+                results.stream()
+                    .filter(result -> !result.succeeded())
+                    .map(bulkManager::toBulkError)
+                    .toArray(BulkError[]::new);
+            return Utils.ok(
+                new BulkRemoveResponse(
+                    names, errors, new BulkSummary(results.size(), 
names.length, errors.length)));
+          });
+    } catch (Exception e) {
+      return ExceptionHandlers.handleGroupException(OperationType.REMOVE, "", 
metalake, e);
+    }
+  }
 }
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestBulkOperations.java
 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestBulkOperations.java
index b55f599758..7a01a4cbb6 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestBulkOperations.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestBulkOperations.java
@@ -44,25 +44,33 @@ import org.apache.gravitino.Config;
 import org.apache.gravitino.EntityStore;
 import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.authorization.AccessControlManager;
+import org.apache.gravitino.authorization.Group;
 import org.apache.gravitino.authorization.OwnerDispatcher;
 import org.apache.gravitino.authorization.User;
 import org.apache.gravitino.bulk.BulkItemResult;
 import org.apache.gravitino.bulk.BulkManager;
+import org.apache.gravitino.bulk.GroupAdd;
 import org.apache.gravitino.bulk.UserAdd;
 import org.apache.gravitino.config.ConfigEntry;
 import org.apache.gravitino.connector.PropertiesMetadata;
+import org.apache.gravitino.dto.requests.BulkGroupAddRequest;
 import org.apache.gravitino.dto.requests.BulkRemoveRequest;
 import org.apache.gravitino.dto.requests.BulkUserAddRequest;
+import org.apache.gravitino.dto.requests.GroupAddRequest;
 import org.apache.gravitino.dto.requests.UserAddRequest;
+import org.apache.gravitino.dto.responses.BulkGroupResponse;
 import org.apache.gravitino.dto.responses.BulkRemoveResponse;
 import org.apache.gravitino.dto.responses.BulkUserResponse;
 import org.apache.gravitino.dto.responses.ErrorConstants;
 import org.apache.gravitino.dto.responses.ErrorResponse;
+import org.apache.gravitino.exceptions.GroupAlreadyExistsException;
+import org.apache.gravitino.exceptions.NoSuchGroupException;
 import org.apache.gravitino.exceptions.NoSuchUserException;
 import org.apache.gravitino.exceptions.UserAlreadyExistsException;
 import org.apache.gravitino.lock.LockManager;
 import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.BaseMetalake;
+import org.apache.gravitino.meta.GroupEntity;
 import org.apache.gravitino.meta.UserEntity;
 import org.apache.gravitino.rest.RESTUtils;
 import org.glassfish.hk2.utilities.binding.AbstractBinder;
@@ -216,6 +224,70 @@ public class TestBulkOperations extends BaseOperationsTest 
{
     Assertions.assertEquals(ErrorConstants.NOT_FOUND_CODE, 
bulkResponse.getErrors()[0].getCode());
   }
 
+  @Test
+  public void testBulkAddGroupsBestEffort() {
+    Group group1 = buildGroup("group1");
+    when(manager.addGroups(any(), any()))
+        .thenReturn(
+            Arrays.asList(
+                BulkItemResult.success(0, "group1", group1),
+                BulkItemResult.failure(
+                    1, "group2", new GroupAlreadyExistsException("Group 
already exists: group2"))));
+
+    BulkGroupAddRequest request =
+        new BulkGroupAddRequest(
+            new GroupAddRequest[] {
+              new GroupAddRequest("group1", "ext-group1"), new 
GroupAddRequest("group2")
+            });
+    Response response =
+        target("/bulk/metalakes/metalake1/groups/add")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE));
+
+    Assertions.assertEquals(Response.Status.OK.getStatusCode(), 
response.getStatus());
+    BulkGroupResponse bulkResponse = 
response.readEntity(BulkGroupResponse.class);
+    Assertions.assertEquals(1, bulkResponse.getGroups().length);
+    Assertions.assertEquals("group1", bulkResponse.getGroups()[0].name());
+    Assertions.assertEquals(1, bulkResponse.getErrors().length);
+    Assertions.assertEquals(1, bulkResponse.getErrors()[0].getIndex());
+    Assertions.assertEquals("group2", bulkResponse.getErrors()[0].getName());
+    Assertions.assertEquals(
+        ErrorConstants.ALREADY_EXISTS_CODE, 
bulkResponse.getErrors()[0].getCode());
+    Assertions.assertEquals(2, bulkResponse.getSummary().getTotal());
+    Assertions.assertEquals(1, bulkResponse.getSummary().getSucceeded());
+    Assertions.assertEquals(1, bulkResponse.getSummary().getFailed());
+
+    ArgumentCaptor<List<GroupAdd>> groupsCaptor = 
ArgumentCaptor.forClass(List.class);
+    Mockito.verify(manager).addGroups(eq("metalake1"), groupsCaptor.capture());
+    Assertions.assertEquals("group1", groupsCaptor.getValue().get(0).name());
+    Assertions.assertEquals("ext-group1", 
groupsCaptor.getValue().get(0).externalId());
+  }
+
+  @Test
+  public void testBulkRemoveGroupsBestEffort() {
+    when(manager.removeGroups(any(), any(), any()))
+        .thenReturn(
+            Arrays.asList(
+                BulkItemResult.success(0, "group1"),
+                BulkItemResult.failure(
+                    1, "ghost", new NoSuchGroupException("Group does not 
exist: ghost"))));
+
+    BulkRemoveRequest request = new BulkRemoveRequest(new String[] {"group1", 
"ghost"});
+    Response response =
+        target("/bulk/metalakes/metalake1/groups/remove")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE));
+
+    Assertions.assertEquals(Response.Status.OK.getStatusCode(), 
response.getStatus());
+    BulkRemoveResponse bulkResponse = 
response.readEntity(BulkRemoveResponse.class);
+    Assertions.assertArrayEquals(new String[] {"group1"}, 
bulkResponse.getNames());
+    Assertions.assertEquals(1, bulkResponse.getErrors().length);
+    Assertions.assertEquals("ghost", bulkResponse.getErrors()[0].getName());
+    Assertions.assertEquals(ErrorConstants.NOT_FOUND_CODE, 
bulkResponse.getErrors()[0].getCode());
+  }
+
   @Test
   public void testBulkRejectsEmptyAndExceededRequest() {
     Response emptyResponse =
@@ -239,6 +311,17 @@ public class TestBulkOperations extends BaseOperationsTest 
{
         Response.Status.BAD_REQUEST.getStatusCode(), 
exceededResponse.getStatus());
     ErrorResponse errorResponse = 
exceededResponse.readEntity(ErrorResponse.class);
     Assertions.assertEquals(ErrorConstants.ILLEGAL_ARGUMENTS_CODE, 
errorResponse.getCode());
+
+    Response emptyGroupResponse =
+        target("/bulk/metalakes/metalake1/groups/add")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .post(
+                Entity.entity(
+                    new BulkGroupAddRequest(new GroupAddRequest[] {}),
+                    MediaType.APPLICATION_JSON_TYPE));
+    Assertions.assertEquals(
+        Response.Status.BAD_REQUEST.getStatusCode(), 
emptyGroupResponse.getStatus());
   }
 
   private User buildUser(String user) {
@@ -250,4 +333,14 @@ public class TestBulkOperations extends BaseOperationsTest 
{
             
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build())
         .build();
   }
+
+  private Group buildGroup(String group) {
+    return GroupEntity.builder()
+        .withId(1L)
+        .withName(group)
+        .withRoleNames(Collections.emptyList())
+        .withAuditInfo(
+            
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build())
+        .build();
+  }
 }

Reply via email to