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 94d2d02ace [#12287] feat(server): Add bulk user access-control APIs 
(#12288)
94d2d02ace is described below

commit 94d2d02ace136bf339c3dbd5aec82d3c0df8064b
Author: jarred0214 <[email protected]>
AuthorDate: Wed Aug 19 15:21:58 2026 +0800

    [#12287] feat(server): Add bulk user access-control APIs (#12288)
    
    ### What changes were proposed in this pull request?
    
    This PR adds the shared foundation for best-effort bulk access-control
    APIs and introduces bulk user operations.
    
    Changes include:
    - Add shared bulk request/response DTOs for item-level errors and
    summary counts.
    - Add `gravitino.server.bulk.maxItems`, defaulting to 100.
    - Add bulk user add/remove REST APIs.
    - Register the bulk REST resource.
    - Add OpenAPI definitions and access-control documentation for bulk user
    APIs.
    - Add tests for bulk user best-effort behavior, duplicate-name
    validation, and request size limit validation.
    
    ### Why are the changes needed?
    
    Bulk access-control operations are needed to efficiently add or remove
    multiple users under a metalake while preserving item-level best-effort
    results.
    
    Fix: #12287
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes.
    
    New REST APIs:
    - `POST /api/bulk/metalakes/{metalake}/users/add`
    - `POST /api/bulk/metalakes/{metalake}/users/remove`
    
    New server configuration:
    - `gravitino.server.bulk.maxItems`, default value: `100`
    
    ### How was this patch tested?
    
    -
    
`JAVA_HOME=/opt/homebrew/Cellar/openjdk@17/17.0.18/libexec/openjdk.jdk/Contents/Home
    ./gradlew :server:spotlessApply :common:spotlessApply :server:test
    --tests org.apache.gravitino.server.web.rest.TestBulkOperations
    -PskipITs`
    -
    
`JAVA_HOME=/opt/homebrew/Cellar/openjdk@17/17.0.18/libexec/openjdk.jdk/Contents/Home
    ./gradlew :docs:build`
    - `git diff --check`
---
 .../gravitino/dto/requests/BulkRemoveRequest.java  |  67 +++++
 .../gravitino/dto/requests/BulkUserAddRequest.java |  62 +++++
 .../apache/gravitino/dto/responses/BulkError.java  |  80 ++++++
 .../dto/responses/BulkRemoveResponse.java          |  71 ++++++
 .../gravitino/dto/responses/BulkSummary.java       |  67 +++++
 .../gravitino/dto/responses/BulkUserResponse.java  |  72 ++++++
 conf/gravitino.conf.template                       |   2 +
 .../main/java/org/apache/gravitino/Configs.java    |  10 +
 .../java/org/apache/gravitino/GravitinoEnv.java    |  13 +
 .../authorization/AccessControlDispatcher.java     |  29 +++
 .../authorization/AccessControlManager.java        |  73 ++++++
 .../org/apache/gravitino/bulk/BulkItemResult.java  | 121 +++++++++
 .../org/apache/gravitino/bulk/BulkManager.java     |  95 +++++++
 .../java/org/apache/gravitino/bulk/UserAdd.java    |  80 ++++++
 .../hook/AccessControlHookDispatcher.java          |  16 ++
 .../listener/AccessControlEventDispatcher.java     |  65 +++++
 .../authorization/TestAccessControlManager.java    |  53 ++++
 .../listener/api/event/TestUserEvent.java          |  76 +++++-
 .../gravitino/rewrite_gravitino_server_config.py   |   4 +-
 docs/gravitino-server-config.md                    |   2 +
 docs/open-api/bulk.yaml                            | 275 +++++++++++++++++++++
 docs/open-api/openapi.yaml                         |   6 +
 docs/security/access-control.md                    |  36 +++
 .../web/filter/GravitinoInterceptionService.java   |   2 +
 .../gravitino/server/web/rest/BulkOperations.java  | 181 ++++++++++++++
 .../server/web/rest/TestBulkOperations.java        | 253 +++++++++++++++++++
 26 files changed, 1808 insertions(+), 3 deletions(-)

diff --git 
a/common/src/main/java/org/apache/gravitino/dto/requests/BulkRemoveRequest.java 
b/common/src/main/java/org/apache/gravitino/dto/requests/BulkRemoveRequest.java
new file mode 100644
index 0000000000..cc89bd87f5
--- /dev/null
+++ 
b/common/src/main/java/org/apache/gravitino/dto/requests/BulkRemoveRequest.java
@@ -0,0 +1,67 @@
+/*
+ * 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 java.util.HashSet;
+import java.util.Set;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.ToString;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.rest.RESTRequest;
+
+/** Represents a request to remove named entities in bulk. */
+@Getter
+@EqualsAndHashCode
+@ToString
+public class BulkRemoveRequest implements RESTRequest {
+
+  @JsonProperty("names")
+  private final String[] names;
+
+  /**
+   * Creates a new BulkRemoveRequest.
+   *
+   * @param names The entity names.
+   */
+  public BulkRemoveRequest(String[] names) {
+    this.names = names;
+  }
+
+  /** Default constructor for BulkRemoveRequest. (Used for Jackson 
deserialization.) */
+  public BulkRemoveRequest() {
+    this(null);
+  }
+
+  @Override
+  public void validate() throws IllegalArgumentException {
+    Preconditions.checkArgument(names != null && names.length > 0, "\"names\" 
must not be empty");
+    Set<String> seen = new HashSet<>();
+    Arrays.stream(names)
+        .forEach(
+            name -> {
+              Preconditions.checkArgument(
+                  StringUtils.isNotBlank(name), "name must not be null or 
empty");
+              Preconditions.checkArgument(seen.add(name), "Duplicate name in 
request: %s", name);
+            });
+  }
+}
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/requests/BulkUserAddRequest.java
 
b/common/src/main/java/org/apache/gravitino/dto/requests/BulkUserAddRequest.java
new file mode 100644
index 0000000000..a840c2bc73
--- /dev/null
+++ 
b/common/src/main/java/org/apache/gravitino/dto/requests/BulkUserAddRequest.java
@@ -0,0 +1,62 @@
+/*
+ * 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 users in bulk. */
+@Getter
+@EqualsAndHashCode
+@ToString
+public class BulkUserAddRequest implements RESTRequest {
+
+  @JsonProperty("users")
+  private final UserAddRequest[] users;
+
+  /**
+   * Creates a new BulkUserAddRequest.
+   *
+   * @param users The user add requests.
+   */
+  public BulkUserAddRequest(UserAddRequest[] users) {
+    this.users = users;
+  }
+
+  /** Default constructor for BulkUserAddRequest. (Used for Jackson 
deserialization.) */
+  public BulkUserAddRequest() {
+    this(null);
+  }
+
+  @Override
+  public void validate() throws IllegalArgumentException {
+    Preconditions.checkArgument(users != null && users.length > 0, "\"users\" 
must not be empty");
+    Arrays.stream(users)
+        .forEach(
+            user -> {
+              Preconditions.checkArgument(user != null, "user must not be 
null");
+              user.validate();
+            });
+  }
+}
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/responses/BulkError.java 
b/common/src/main/java/org/apache/gravitino/dto/responses/BulkError.java
new file mode 100644
index 0000000000..a5b1ec74ed
--- /dev/null
+++ b/common/src/main/java/org/apache/gravitino/dto/responses/BulkError.java
@@ -0,0 +1,80 @@
+/*
+ * 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 javax.annotation.Nullable;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.ToString;
+import org.apache.commons.lang3.StringUtils;
+
+/** Represents an item-level error in a bulk response. */
+@Getter
+@ToString
+@EqualsAndHashCode
+public class BulkError {
+
+  @JsonProperty("index")
+  private final int index;
+
+  @Nullable
+  @JsonProperty("name")
+  private final String name;
+
+  @JsonProperty("code")
+  private final int code;
+
+  @JsonProperty("type")
+  private final String type;
+
+  @JsonProperty("message")
+  private final String message;
+
+  /**
+   * Creates a new BulkError.
+   *
+   * @param index The zero-based index of the failed request item.
+   * @param name The name of the failed request item.
+   * @param code The Gravitino error code.
+   * @param type The error type.
+   * @param message The error message.
+   */
+  public BulkError(int index, @Nullable String name, int code, String type, 
String message) {
+    this.index = index;
+    this.name = name;
+    this.code = code;
+    this.type = type;
+    this.message = message;
+  }
+
+  /** Default constructor for BulkError. (Used for Jackson deserialization.) */
+  public BulkError() {
+    this(-1, null, 0, null, null);
+  }
+
+  /** Validates the bulk error. */
+  public void validate() {
+    Preconditions.checkArgument(index >= 0, "index must be >= 0");
+    Preconditions.checkArgument(code > 0, "code must be > 0");
+    Preconditions.checkArgument(StringUtils.isNotBlank(type), "type must not 
be blank");
+    Preconditions.checkArgument(StringUtils.isNotBlank(message), "message must 
not be blank");
+  }
+}
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/responses/BulkRemoveResponse.java
 
b/common/src/main/java/org/apache/gravitino/dto/responses/BulkRemoveResponse.java
new file mode 100644
index 0000000000..b02b3fb8c2
--- /dev/null
+++ 
b/common/src/main/java/org/apache/gravitino/dto/responses/BulkRemoveResponse.java
@@ -0,0 +1,71 @@
+/*
+ * 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;
+
+/** Represents a bulk remove response. */
+@Getter
+@ToString
+@EqualsAndHashCode(callSuper = true)
+public class BulkRemoveResponse extends BaseResponse {
+
+  @JsonProperty("names")
+  private final String[] names;
+
+  @JsonProperty("errors")
+  private final BulkError[] errors;
+
+  @JsonProperty("summary")
+  private final BulkSummary summary;
+
+  /**
+   * Creates a new BulkRemoveResponse.
+   *
+   * @param names The successfully removed names.
+   * @param errors The item-level errors.
+   * @param summary The summary counts.
+   */
+  public BulkRemoveResponse(String[] names, BulkError[] errors, BulkSummary 
summary) {
+    super(0);
+    this.names = names;
+    this.errors = errors;
+    this.summary = summary;
+  }
+
+  /** Default constructor for BulkRemoveResponse. (Used for Jackson 
deserialization.) */
+  public BulkRemoveResponse() {
+    this(null, null, null);
+  }
+
+  @Override
+  public void validate() throws IllegalArgumentException {
+    super.validate();
+    Preconditions.checkArgument(names != null, "names 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/common/src/main/java/org/apache/gravitino/dto/responses/BulkSummary.java 
b/common/src/main/java/org/apache/gravitino/dto/responses/BulkSummary.java
new file mode 100644
index 0000000000..28c86a9118
--- /dev/null
+++ b/common/src/main/java/org/apache/gravitino/dto/responses/BulkSummary.java
@@ -0,0 +1,67 @@
+/*
+ * 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 lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.ToString;
+
+/** Represents summary counts for a bulk response. */
+@Getter
+@ToString
+@EqualsAndHashCode
+public class BulkSummary {
+
+  @JsonProperty("total")
+  private final int total;
+
+  @JsonProperty("succeeded")
+  private final int succeeded;
+
+  @JsonProperty("failed")
+  private final int failed;
+
+  /**
+   * Creates a new BulkSummary.
+   *
+   * @param total The total number of request items.
+   * @param succeeded The number of succeeded request items.
+   * @param failed The number of failed request items.
+   */
+  public BulkSummary(int total, int succeeded, int failed) {
+    this.total = total;
+    this.succeeded = succeeded;
+    this.failed = failed;
+  }
+
+  /** Default constructor for BulkSummary. (Used for Jackson deserialization.) 
*/
+  public BulkSummary() {
+    this(0, 0, 0);
+  }
+
+  /** Validates the bulk summary. */
+  public void validate() {
+    Preconditions.checkArgument(total >= 0, "total must be >= 0");
+    Preconditions.checkArgument(succeeded >= 0, "succeeded must be >= 0");
+    Preconditions.checkArgument(failed >= 0, "failed must be >= 0");
+    Preconditions.checkArgument(total == succeeded + failed, "total must equal 
succeeded + failed");
+  }
+}
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/responses/BulkUserResponse.java 
b/common/src/main/java/org/apache/gravitino/dto/responses/BulkUserResponse.java
new file mode 100644
index 0000000000..96deff2a76
--- /dev/null
+++ 
b/common/src/main/java/org/apache/gravitino/dto/responses/BulkUserResponse.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.UserDTO;
+
+/** Represents a bulk user response. */
+@Getter
+@ToString
+@EqualsAndHashCode(callSuper = true)
+public class BulkUserResponse extends BaseResponse {
+
+  @JsonProperty("users")
+  private final UserDTO[] users;
+
+  @JsonProperty("errors")
+  private final BulkError[] errors;
+
+  @JsonProperty("summary")
+  private final BulkSummary summary;
+
+  /**
+   * Creates a new BulkUserResponse.
+   *
+   * @param users The successfully added users.
+   * @param errors The item-level errors.
+   * @param summary The summary counts.
+   */
+  public BulkUserResponse(UserDTO[] users, BulkError[] errors, BulkSummary 
summary) {
+    super(0);
+    this.users = users;
+    this.errors = errors;
+    this.summary = summary;
+  }
+
+  /** Default constructor for BulkUserResponse. (Used for Jackson 
deserialization.) */
+  public BulkUserResponse() {
+    this(null, null, null);
+  }
+
+  @Override
+  public void validate() throws IllegalArgumentException {
+    super.validate();
+    Preconditions.checkArgument(users != null, "users 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/conf/gravitino.conf.template b/conf/gravitino.conf.template
index 4d936dda34..c9afcc025c 100644
--- a/conf/gravitino.conf.template
+++ b/conf/gravitino.conf.template
@@ -21,6 +21,8 @@
 gravitino.server.shutdown.timeout = 3000
 # Timeout in milliseconds for the entity-store readiness probe used by 
/api/health/ready
 # gravitino.server.health.entityStore.probeTimeoutMs = 2000
+# Maximum number of items allowed in a single bulk request
+gravitino.server.bulk.maxItems = 100
 
 # THE CONFIGURATION FOR Gravitino WEB SERVER
 # The host name of the built-in web server
diff --git a/core/src/main/java/org/apache/gravitino/Configs.java 
b/core/src/main/java/org/apache/gravitino/Configs.java
index 4ed70e1bec..ed47ef8b5a 100644
--- a/core/src/main/java/org/apache/gravitino/Configs.java
+++ b/core/src/main/java/org/apache/gravitino/Configs.java
@@ -97,6 +97,8 @@ public class Configs {
 
   public static final int DEFAULT_GRAVITINO_AUTHORIZATION_THREAD_POOL_SIZE = 
100;
 
+  public static final int DEFAULT_BULK_MAX_ITEMS = 100;
+
   public static final long 
DEFAULT_RELATIONAL_JDBC_BACKEND_MAX_WAIT_MILLISECONDS = 1000L;
 
   public static final int GARBAGE_COLLECTOR_SINGLE_DELETION_LIMIT = 100;
@@ -346,6 +348,14 @@ public class Configs {
           .intConf()
           .createWithDefault(DEFAULT_GRAVITINO_AUTHORIZATION_THREAD_POOL_SIZE);
 
+  public static final ConfigEntry<Integer> BULK_MAX_ITEMS =
+      new ConfigBuilder("gravitino.server.bulk.maxItems")
+          .doc("The maximum number of items allowed in a single bulk request")
+          .version(ConfigConstants.VERSION_2_0_0)
+          .intConf()
+          .checkValue(value -> value > 0, 
ConfigConstants.POSITIVE_NUMBER_ERROR_MSG)
+          .createWithDefault(DEFAULT_BULK_MAX_ITEMS);
+
   public static final long 
DEFAULT_GRAVITINO_AUTHORIZATION_CACHE_EXPIRATION_SECS = 3600L;
 
   public static final ConfigEntry<Long> 
GRAVITINO_AUTHORIZATION_CACHE_EXPIRATION_SECS =
diff --git a/core/src/main/java/org/apache/gravitino/GravitinoEnv.java 
b/core/src/main/java/org/apache/gravitino/GravitinoEnv.java
index def00e8cbc..e0a671b844 100644
--- a/core/src/main/java/org/apache/gravitino/GravitinoEnv.java
+++ b/core/src/main/java/org/apache/gravitino/GravitinoEnv.java
@@ -28,6 +28,7 @@ import org.apache.gravitino.authorization.OwnerDispatcher;
 import org.apache.gravitino.authorization.OwnerEventManager;
 import org.apache.gravitino.authorization.OwnerManager;
 import org.apache.gravitino.auxiliary.AuxiliaryServiceManager;
+import org.apache.gravitino.bulk.BulkManager;
 import org.apache.gravitino.catalog.CatalogDispatcher;
 import org.apache.gravitino.catalog.CatalogManager;
 import org.apache.gravitino.catalog.CatalogNormalizeDispatcher;
@@ -185,6 +186,7 @@ public class GravitinoEnv {
   private EventBus eventBus;
   private OwnerDispatcher ownerDispatcher;
   private OwnerDispatcher internalOwnerDispatcher;
+  private BulkManager bulkManager;
   private FutureGrantManager futureGrantManager;
   private GravitinoAuthorizer gravitinoAuthorizer;
   private StatisticDispatcher statisticDispatcher;
@@ -526,6 +528,15 @@ public class GravitinoEnv {
     return internalAccessControlDispatcher;
   }
 
+  /**
+   * Get the BulkManager associated with the Gravitino environment.
+   *
+   * @return The BulkManager instance.
+   */
+  public BulkManager bulkManager() {
+    return bulkManager;
+  }
+
   /**
    * Get the tagDispatcher associated with the Gravitino environment.
    *
@@ -858,12 +869,14 @@ public class GravitinoEnv {
       OwnerDispatcher ownerManager = new OwnerManager(entityStore);
       this.internalOwnerDispatcher = ownerManager;
       this.ownerDispatcher = new OwnerEventManager(eventBus, ownerManager);
+      this.bulkManager = new BulkManager(config);
       this.futureGrantManager = new FutureGrantManager(entityStore, 
ownerManager);
     } else {
       this.accessControlDispatcher = null;
       this.internalAccessControlDispatcher = null;
       this.ownerDispatcher = null;
       this.internalOwnerDispatcher = null;
+      this.bulkManager = null;
       this.futureGrantManager = null;
     }
 
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 25b48b1067..e91af8c6e9 100644
--- 
a/core/src/main/java/org/apache/gravitino/authorization/AccessControlDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/AccessControlDispatcher.java
@@ -20,8 +20,11 @@ package org.apache.gravitino.authorization;
 
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.bulk.BulkItemResult;
+import org.apache.gravitino.bulk.UserAdd;
 import org.apache.gravitino.exceptions.GroupAlreadyExistsException;
 import org.apache.gravitino.exceptions.IllegalRoleException;
 import org.apache.gravitino.exceptions.NoSuchGroupException;
@@ -66,6 +69,18 @@ public interface AccessControlDispatcher {
   User addUser(String metalake, String user, String externalId, boolean 
enabled)
       throws UserAlreadyExistsException, NoSuchMetalakeException;
 
+  /**
+   * Adds users in bulk.
+   *
+   * @param metalake The Metalake of the Users.
+   * @param users The Users 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 Users encounters storage issues.
+   */
+  List<BulkItemResult<User>> addUsers(String metalake, List<UserAdd> users)
+      throws NoSuchMetalakeException;
+
   /**
    * Removes a User.
    *
@@ -78,6 +93,20 @@ public interface AccessControlDispatcher {
    */
   boolean removeUser(String metalake, String user) throws 
NoSuchMetalakeException;
 
+  /**
+   * Removes Users in bulk.
+   *
+   * @param metalake The Metalake of the Users.
+   * @param users The names of the Users.
+   * @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 Users encounters storage issues.
+   */
+  List<BulkItemResult<String>> removeUsers(
+      String metalake, List<String> users, Optional<Owner> metalakeOwner)
+      throws NoSuchMetalakeException;
+
   /**
    * Removes a User 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 91dc0d1781..05b44dc4c2 100644
--- 
a/core/src/main/java/org/apache/gravitino/authorization/AccessControlManager.java
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/AccessControlManager.java
@@ -18,14 +18,18 @@
  */
 package org.apache.gravitino.authorization;
 
+import com.google.common.collect.Lists;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import org.apache.gravitino.Config;
 import org.apache.gravitino.Configs;
 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.UserAdd;
 import org.apache.gravitino.exceptions.GroupAlreadyExistsException;
 import org.apache.gravitino.exceptions.IllegalRoleException;
 import org.apache.gravitino.exceptions.NoSuchGroupException;
@@ -80,6 +84,34 @@ public class AccessControlManager implements 
AccessControlDispatcher {
         () -> userGroupExternalManager.addUser(metalake, user, externalId, 
enabled));
   }
 
+  @Override
+  public List<BulkItemResult<User>> addUsers(String metalake, List<UserAdd> 
users)
+      throws NoSuchMetalakeException {
+    return TreeLockUtils.doWithTreeLock(
+        
NameIdentifier.of(AuthorizationUtils.ofUserNamespace(metalake).levels()),
+        LockType.WRITE,
+        () -> {
+          List<BulkItemResult<User>> results = 
Lists.newArrayListWithCapacity(users.size());
+          for (int index = 0; index < users.size(); index++) {
+            UserAdd user = users.get(index);
+            try {
+              User addedUser =
+                  user.hasExternalId()
+                      ? userGroupExternalManager.addUser(
+                          metalake,
+                          user.name(),
+                          user.externalId(),
+                          Optional.ofNullable(user.enabled()).orElse(true))
+                      : userGroupManager.addUser(metalake, user.name());
+              results.add(BulkItemResult.success(index, user.name(), 
addedUser));
+            } catch (Exception e) {
+              results.add(BulkItemResult.failure(index, user.name(), e));
+            }
+          }
+          return results;
+        });
+  }
+
   @Override
   public boolean removeUser(String metalake, String user) throws 
NoSuchMetalakeException {
     return TreeLockUtils.doWithTreeLock(
@@ -88,6 +120,35 @@ public class AccessControlManager implements 
AccessControlDispatcher {
         () -> userGroupManager.removeUser(metalake, user));
   }
 
+  @Override
+  public List<BulkItemResult<String>> removeUsers(
+      String metalake, List<String> users, Optional<Owner> metalakeOwner)
+      throws NoSuchMetalakeException {
+    return TreeLockUtils.doWithTreeLock(
+        
NameIdentifier.of(AuthorizationUtils.ofUserNamespace(metalake).levels()),
+        LockType.WRITE,
+        () -> {
+          List<BulkItemResult<String>> results = 
Lists.newArrayListWithCapacity(users.size());
+          for (int index = 0; index < users.size(); index++) {
+            String user = users.get(index);
+            try {
+              ensureNotMetalakeOwner(metalakeOwner, metalake, user);
+              boolean removed = userGroupManager.removeUser(metalake, user);
+              if (!removed) {
+                results.add(
+                    BulkItemResult.failure(
+                        index, user, new NoSuchUserException("User does not 
exist: %s", user)));
+                continue;
+              }
+              results.add(BulkItemResult.success(index, user));
+            } catch (Exception e) {
+              results.add(BulkItemResult.failure(index, user, e));
+            }
+          }
+          return results;
+        });
+  }
+
   @Override
   public boolean removeUserByExternalId(String metalake, String externalId)
       throws NoSuchMetalakeException {
@@ -402,4 +463,16 @@ public class AccessControlManager implements 
AccessControlDispatcher {
         () ->
             permissionManager.overridePrivilegesInRole(metalake, role, 
securableObjectsToOverride));
   }
+
+  private void ensureNotMetalakeOwner(Optional<Owner> metalakeOwner, String 
metalake, String user) {
+    metalakeOwner.ifPresent(
+        owner -> {
+          if (owner.type() == Owner.Type.USER && owner.name().equals(user)) {
+            throw new IllegalArgumentException(
+                String.format(
+                    "Cannot remove user %s from metalake %s because the user 
is the owner of the metalake.",
+                    user, metalake));
+          }
+        });
+  }
 }
diff --git a/core/src/main/java/org/apache/gravitino/bulk/BulkItemResult.java 
b/core/src/main/java/org/apache/gravitino/bulk/BulkItemResult.java
new file mode 100644
index 0000000000..074a8b88d0
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/bulk/BulkItemResult.java
@@ -0,0 +1,121 @@
+/*
+ * 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.bulk;
+
+import java.util.Optional;
+import javax.annotation.Nullable;
+
+/** Represents the result of one item in a best-effort bulk operation. */
+public final class BulkItemResult<T> {
+
+  private final int index;
+  private final String name;
+  @Nullable private final T value;
+  @Nullable private final Exception error;
+
+  private BulkItemResult(int index, String name, @Nullable T value, @Nullable 
Exception error) {
+    this.index = index;
+    this.name = name;
+    this.value = value;
+    this.error = error;
+  }
+
+  /**
+   * Creates a successful item result with a value.
+   *
+   * @param index The item index in the request.
+   * @param name The item name.
+   * @param value The successful value.
+   * @return The successful item result.
+   * @param <T> The successful value type.
+   */
+  public static <T> BulkItemResult<T> success(int index, String name, T value) 
{
+    return new BulkItemResult<>(index, name, value, null);
+  }
+
+  /**
+   * Creates a successful item result without a value.
+   *
+   * @param index The item index in the request.
+   * @param name The item name.
+   * @return The successful item result.
+   * @param <T> The successful value type.
+   */
+  public static <T> BulkItemResult<T> success(int index, String name) {
+    return new BulkItemResult<>(index, name, null, null);
+  }
+
+  /**
+   * Creates a failed item result.
+   *
+   * @param index The item index in the request.
+   * @param name The item name.
+   * @param error The item-level error.
+   * @return The failed item result.
+   * @param <T> The successful value type.
+   */
+  public static <T> BulkItemResult<T> failure(int index, String name, 
Exception error) {
+    return new BulkItemResult<>(index, name, null, error);
+  }
+
+  /**
+   * Returns the item index in the request.
+   *
+   * @return The item index.
+   */
+  public int index() {
+    return index;
+  }
+
+  /**
+   * Returns the item name.
+   *
+   * @return The item name.
+   */
+  public String name() {
+    return name;
+  }
+
+  /**
+   * Returns whether the item succeeded.
+   *
+   * @return True if the item succeeded, otherwise false.
+   */
+  public boolean succeeded() {
+    return error == null;
+  }
+
+  /**
+   * Returns the successful value.
+   *
+   * @return The successful value.
+   */
+  public Optional<T> value() {
+    return Optional.ofNullable(value);
+  }
+
+  /**
+   * Returns the item-level error.
+   *
+   * @return The item-level error.
+   */
+  public Optional<Exception> error() {
+    return Optional.ofNullable(error);
+  }
+}
diff --git a/core/src/main/java/org/apache/gravitino/bulk/BulkManager.java 
b/core/src/main/java/org/apache/gravitino/bulk/BulkManager.java
new file mode 100644
index 0000000000..1fccbc55c4
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/bulk/BulkManager.java
@@ -0,0 +1,95 @@
+/*
+ * 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.bulk;
+
+import org.apache.gravitino.Config;
+import org.apache.gravitino.Configs;
+import org.apache.gravitino.dto.responses.BulkError;
+import org.apache.gravitino.dto.responses.ErrorConstants;
+import org.apache.gravitino.exceptions.AlreadyExistsException;
+import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.gravitino.exceptions.NotFoundException;
+import org.apache.gravitino.exceptions.NotInUseException;
+
+/** Manages best-effort bulk operations. */
+public class BulkManager {
+
+  private final int maxBulkItems;
+
+  /**
+   * Creates a new {@link BulkManager}.
+   *
+   * @param config The Gravitino configuration.
+   */
+  public BulkManager(Config config) {
+    this.maxBulkItems =
+        config == null
+            ? Configs.BULK_MAX_ITEMS.getDefaultValue()
+            : config.get(Configs.BULK_MAX_ITEMS);
+  }
+
+  /**
+   * Checks whether the request item size exceeds the configured bulk limit.
+   *
+   * @param fieldName The request field name.
+   * @param size The request item size.
+   */
+  public void checkBulkSize(String fieldName, int size) {
+    if (size > maxBulkItems) {
+      throw new IllegalArgumentException(
+          String.format(
+              "\"%s\" size %d exceeds the maximum allowed bulk items %d",
+              fieldName, size, maxBulkItems));
+    }
+  }
+
+  private int errorCode(Exception e) {
+    if (e instanceof IllegalArgumentException) {
+      return ErrorConstants.ILLEGAL_ARGUMENTS_CODE;
+    } else if (e instanceof NotFoundException) {
+      return ErrorConstants.NOT_FOUND_CODE;
+    } else if (e instanceof AlreadyExistsException) {
+      return ErrorConstants.ALREADY_EXISTS_CODE;
+    } else if (e instanceof ForbiddenException) {
+      return ErrorConstants.FORBIDDEN_CODE;
+    } else if (e instanceof NotInUseException) {
+      return ErrorConstants.NOT_IN_USE_CODE;
+    }
+    return ErrorConstants.INTERNAL_ERROR_CODE;
+  }
+
+  /**
+   * Converts an item-level exception to a bulk error.
+   *
+   * @param result The failed item result.
+   * @return The bulk error.
+   */
+  public BulkError toBulkError(BulkItemResult<?> result) {
+    Exception error =
+        result
+            .error()
+            .orElseThrow(() -> new IllegalArgumentException("Bulk item result 
has no error"));
+    return new BulkError(
+        result.index(),
+        result.name(),
+        errorCode(error),
+        error.getClass().getSimpleName(),
+        error.getMessage());
+  }
+}
diff --git a/core/src/main/java/org/apache/gravitino/bulk/UserAdd.java 
b/core/src/main/java/org/apache/gravitino/bulk/UserAdd.java
new file mode 100644
index 0000000000..d62fd46a4c
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/bulk/UserAdd.java
@@ -0,0 +1,80 @@
+/*
+ * 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.bulk;
+
+import javax.annotation.Nullable;
+
+/** Represents one user to add in a bulk operation. */
+public final class UserAdd {
+
+  private final String name;
+  @Nullable private final String externalId;
+  @Nullable private final Boolean enabled;
+
+  /**
+   * Creates a user add item.
+   *
+   * @param name The user 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) {
+    this.name = name;
+    this.externalId = externalId;
+    this.enabled = enabled;
+  }
+
+  /**
+   * Returns the user name.
+   *
+   * @return The user name.
+   */
+  public String name() {
+    return name;
+  }
+
+  /**
+   * Returns the external identifier.
+   *
+   * @return The external identifier, or null if unset.
+   */
+  @Nullable
+  public String externalId() {
+    return externalId;
+  }
+
+  /**
+   * Returns whether the user has an external identifier.
+   *
+   * @return True if the user has an 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;
+  }
+}
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 3d856a61f8..570626eaee 100644
--- 
a/core/src/main/java/org/apache/gravitino/hook/AccessControlHookDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/hook/AccessControlHookDispatcher.java
@@ -20,6 +20,7 @@ package org.apache.gravitino.hook;
 
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.GravitinoEnv;
@@ -37,6 +38,8 @@ import org.apache.gravitino.authorization.Role;
 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.UserAdd;
 import org.apache.gravitino.exceptions.GroupAlreadyExistsException;
 import org.apache.gravitino.exceptions.IllegalRoleException;
 import org.apache.gravitino.exceptions.NoSuchGroupException;
@@ -78,11 +81,24 @@ public class AccessControlHookDispatcher implements 
AccessControlDispatcher {
     return dispatcher.addUser(metalake, user, externalId, enabled);
   }
 
+  @Override
+  public List<BulkItemResult<User>> addUsers(String metalake, List<UserAdd> 
users)
+      throws NoSuchMetalakeException {
+    return dispatcher.addUsers(metalake, users);
+  }
+
   @Override
   public boolean removeUser(String metalake, String user) throws 
NoSuchMetalakeException {
     return dispatcher.removeUser(metalake, user);
   }
 
+  @Override
+  public List<BulkItemResult<String>> removeUsers(
+      String metalake, List<String> users, Optional<Owner> metalakeOwner)
+      throws NoSuchMetalakeException {
+    return dispatcher.removeUsers(metalake, users, metalakeOwner);
+  }
+
   @Override
   public boolean removeUserByExternalId(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 a0e2517b1a..95e4aa7125 100644
--- 
a/core/src/main/java/org/apache/gravitino/listener/AccessControlEventDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/listener/AccessControlEventDispatcher.java
@@ -21,17 +21,21 @@ package org.apache.gravitino.listener;
 
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.authorization.AccessControlDispatcher;
 import org.apache.gravitino.authorization.Group;
 import org.apache.gravitino.authorization.GroupChange;
+import org.apache.gravitino.authorization.Owner;
 import org.apache.gravitino.authorization.PagedResult;
 import org.apache.gravitino.authorization.Privilege;
 import org.apache.gravitino.authorization.Role;
 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.UserAdd;
 import org.apache.gravitino.exceptions.GroupAlreadyExistsException;
 import org.apache.gravitino.exceptions.IllegalRoleException;
 import org.apache.gravitino.exceptions.NoSuchGroupException;
@@ -208,6 +212,26 @@ public class AccessControlEventDispatcher implements 
AccessControlDispatcher {
     }
   }
 
+  /** {@inheritDoc} */
+  @Override
+  public List<BulkItemResult<User>> addUsers(String metalake, List<UserAdd> 
users)
+      throws NoSuchMetalakeException {
+    String initiator = PrincipalUtils.getCurrentUserName();
+    users.forEach(
+        user -> eventBus.dispatchEvent(new AddUserPreEvent(initiator, 
metalake, user.name())));
+
+    try {
+      List<BulkItemResult<User>> results = dispatcher.addUsers(metalake, 
users);
+      results.forEach(result -> dispatchAddUserResultEvent(initiator, 
metalake, result));
+      return results;
+    } catch (Exception e) {
+      users.forEach(
+          user ->
+              eventBus.dispatchEvent(new AddUserFailureEvent(initiator, 
metalake, e, user.name())));
+      throw e;
+    }
+  }
+
   /** {@inheritDoc} */
   @Override
   public boolean removeUser(String metalake, String user) throws 
NoSuchMetalakeException {
@@ -225,6 +249,26 @@ public class AccessControlEventDispatcher implements 
AccessControlDispatcher {
     }
   }
 
+  /** {@inheritDoc} */
+  @Override
+  public List<BulkItemResult<String>> removeUsers(
+      String metalake, List<String> users, Optional<Owner> metalakeOwner)
+      throws NoSuchMetalakeException {
+    String initiator = PrincipalUtils.getCurrentUserName();
+    users.forEach(
+        user -> eventBus.dispatchEvent(new RemoveUserPreEvent(initiator, 
metalake, user)));
+
+    try {
+      List<BulkItemResult<String>> results = dispatcher.removeUsers(metalake, 
users, metalakeOwner);
+      results.forEach(result -> dispatchRemoveUserResultEvent(initiator, 
metalake, result));
+      return results;
+    } catch (Exception e) {
+      users.forEach(
+          user -> eventBus.dispatchEvent(new RemoveUserFailureEvent(initiator, 
metalake, e, user)));
+      throw e;
+    }
+  }
+
   /** {@inheritDoc} */
   @Override
   public boolean removeUserByExternalId(String metalake, String externalId)
@@ -900,4 +944,25 @@ public class AccessControlEventDispatcher implements 
AccessControlDispatcher {
       throw e;
     }
   }
+
+  private void dispatchAddUserResultEvent(
+      String initiator, String metalake, BulkItemResult<User> result) {
+    if (result.succeeded()) {
+      eventBus.dispatchEvent(
+          new AddUserEvent(initiator, metalake, new 
UserInfo(result.value().get())));
+    } else {
+      eventBus.dispatchEvent(
+          new AddUserFailureEvent(initiator, metalake, result.error().get(), 
result.name()));
+    }
+  }
+
+  private void dispatchRemoveUserResultEvent(
+      String initiator, String metalake, BulkItemResult<String> result) {
+    if (result.succeeded()) {
+      eventBus.dispatchEvent(new RemoveUserEvent(initiator, metalake, 
result.name(), true));
+    } else {
+      eventBus.dispatchEvent(
+          new RemoveUserFailureEvent(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 7b6686c6c1..d72cefa451 100644
--- 
a/core/src/test/java/org/apache/gravitino/authorization/TestAccessControlManager.java
+++ 
b/core/src/test/java/org/apache/gravitino/authorization/TestAccessControlManager.java
@@ -53,6 +53,7 @@ import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.UUID;
 import org.apache.commons.lang3.reflect.FieldUtils;
 import org.apache.gravitino.Catalog;
@@ -63,6 +64,8 @@ import org.apache.gravitino.EntityStoreFactory;
 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.UserAdd;
 import org.apache.gravitino.catalog.CatalogManager;
 import org.apache.gravitino.connector.BaseCatalog;
 import org.apache.gravitino.connector.authorization.AuthorizationPlugin;
@@ -246,6 +249,56 @@ public class TestAccessControlManager {
     Assertions.assertFalse(removed1);
   }
 
+  @Test
+  public void testBulkAddUsers() {
+    List<BulkItemResult<User>> results =
+        accessControlManager.addUsers(
+            METALAKE,
+            Lists.newArrayList(
+                new UserAdd("bulk_user_1", "bulk-user-ext-1", false),
+                new UserAdd("bulk_user_2", null, null),
+                new UserAdd("bulk_user_1", null, null)));
+
+    Assertions.assertEquals(3, results.size());
+    Assertions.assertTrue(results.get(0).succeeded());
+    Assertions.assertEquals("bulk_user_1", 
results.get(0).value().get().name());
+    Assertions.assertEquals("bulk-user-ext-1", 
results.get(0).value().get().externalId());
+    Assertions.assertFalse(results.get(0).value().get().enabled());
+    Assertions.assertTrue(results.get(1).succeeded());
+    Assertions.assertFalse(results.get(2).succeeded());
+    Assertions.assertTrue(results.get(2).error().get() instanceof 
UserAlreadyExistsException);
+  }
+
+  @Test
+  public void testBulkRemoveUsers() {
+    accessControlManager.addUser(METALAKE, "bulk_remove_user");
+
+    List<BulkItemResult<String>> results =
+        accessControlManager.removeUsers(
+            METALAKE,
+            Lists.newArrayList("bulk_remove_user", "missing_bulk_user", 
"metalake_owner"),
+            Optional.of(
+                new Owner() {
+                  @Override
+                  public String name() {
+                    return "metalake_owner";
+                  }
+
+                  @Override
+                  public Type type() {
+                    return Type.USER;
+                  }
+                }));
+
+    Assertions.assertEquals(3, results.size());
+    Assertions.assertTrue(results.get(0).succeeded());
+    Assertions.assertEquals("bulk_remove_user", results.get(0).name());
+    Assertions.assertFalse(results.get(1).succeeded());
+    Assertions.assertTrue(results.get(1).error().get() instanceof 
NoSuchUserException);
+    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/TestUserEvent.java 
b/core/src/test/java/org/apache/gravitino/listener/api/event/TestUserEvent.java
index b38b0bc453..8845a36bf5 100644
--- 
a/core/src/test/java/org/apache/gravitino/listener/api/event/TestUserEvent.java
+++ 
b/core/src/test/java/org/apache/gravitino/listener/api/event/TestUserEvent.java
@@ -34,6 +34,8 @@ import org.apache.gravitino.authorization.AuthorizationUtils;
 import org.apache.gravitino.authorization.PagedResult;
 import org.apache.gravitino.authorization.User;
 import org.apache.gravitino.authorization.UserChange;
+import org.apache.gravitino.bulk.BulkItemResult;
+import org.apache.gravitino.bulk.UserAdd;
 import org.apache.gravitino.exceptions.GravitinoRuntimeException;
 import org.apache.gravitino.exceptions.NoSuchMetalakeException;
 import org.apache.gravitino.exceptions.NoSuchUserException;
@@ -87,8 +89,6 @@ public class TestUserEvent {
     this.dispatcher = new AccessControlEventDispatcher(eventBus, 
mockUserDispatcher());
     this.failureDispatcher =
         new AccessControlEventDispatcher(eventBus, 
mockExceptionUserDispatcher());
-
-    System.out.println(failureDispatcher);
   }
 
   @Test
@@ -583,6 +583,37 @@ public class TestUserEvent {
     Assertions.assertEquals(OperationType.ADD_USER, event.operationType());
   }
 
+  @Test
+  void testBulkAddUsersDispatchesPerUserEvents() {
+    dummyEventListener.clear();
+
+    dispatcher.addUsers(
+        METALAKE,
+        Arrays.asList(
+            new UserAdd(userName, USER_EXT_ID, true), new 
UserAdd(otherUserName, null, null)));
+
+    Assertions.assertEquals(2, dummyEventListener.getPreEvents().size());
+    PreEvent firstPreEvent = dummyEventListener.getPreEvents().get(0);
+    Assertions.assertEquals(AddUserPreEvent.class, firstPreEvent.getClass());
+    Assertions.assertEquals(identifier, firstPreEvent.identifier());
+    Assertions.assertEquals(userName, ((AddUserPreEvent) 
firstPreEvent).userName());
+    PreEvent secondPreEvent = dummyEventListener.getPreEvents().get(1);
+    Assertions.assertEquals(AddUserPreEvent.class, secondPreEvent.getClass());
+    Assertions.assertEquals(otherIdentifier, secondPreEvent.identifier());
+    Assertions.assertEquals(otherUserName, ((AddUserPreEvent) 
secondPreEvent).userName());
+
+    Assertions.assertEquals(2, dummyEventListener.getPostEvents().size());
+    Event firstEvent = dummyEventListener.getPostEvents().get(0);
+    Assertions.assertEquals(AddUserEvent.class, firstEvent.getClass());
+    Assertions.assertEquals(OperationStatus.SUCCESS, 
firstEvent.operationStatus());
+    validateUserInfo(((AddUserEvent) firstEvent).addedUserInfo(), user);
+    Event secondEvent = dummyEventListener.getPostEvents().get(1);
+    Assertions.assertEquals(AddUserFailureEvent.class, secondEvent.getClass());
+    Assertions.assertEquals(OperationStatus.FAILURE, 
secondEvent.operationStatus());
+    Assertions.assertEquals(otherIdentifier, secondEvent.identifier());
+    Assertions.assertEquals(otherUserName, ((AddUserFailureEvent) 
secondEvent).userName());
+  }
+
   @Test
   void testGetUserByExternalIdEvent() {
     dispatcher.getUserByExternalId(METALAKE, USER_EXT_ID);
@@ -610,6 +641,35 @@ public class TestUserEvent {
     Assertions.assertEquals(OperationType.REMOVE_USER_BY_EXTERNAL_ID, 
event.operationType());
   }
 
+  @Test
+  void testBulkRemoveUsersDispatchesPerUserEvents() {
+    dummyEventListener.clear();
+
+    dispatcher.removeUsers(METALAKE, Arrays.asList(userName, inExistUserName), 
Optional.empty());
+
+    Assertions.assertEquals(2, dummyEventListener.getPreEvents().size());
+    PreEvent firstPreEvent = dummyEventListener.getPreEvents().get(0);
+    Assertions.assertEquals(RemoveUserPreEvent.class, 
firstPreEvent.getClass());
+    Assertions.assertEquals(identifier, firstPreEvent.identifier());
+    Assertions.assertEquals(userName, ((RemoveUserPreEvent) 
firstPreEvent).userName());
+    PreEvent secondPreEvent = dummyEventListener.getPreEvents().get(1);
+    Assertions.assertEquals(RemoveUserPreEvent.class, 
secondPreEvent.getClass());
+    Assertions.assertEquals(inExistIdentifier, secondPreEvent.identifier());
+    Assertions.assertEquals(inExistUserName, ((RemoveUserPreEvent) 
secondPreEvent).userName());
+
+    Assertions.assertEquals(2, dummyEventListener.getPostEvents().size());
+    Event firstEvent = dummyEventListener.getPostEvents().get(0);
+    Assertions.assertEquals(RemoveUserEvent.class, firstEvent.getClass());
+    Assertions.assertEquals(OperationStatus.SUCCESS, 
firstEvent.operationStatus());
+    Assertions.assertEquals(userName, ((RemoveUserEvent) 
firstEvent).removedUserName());
+    Assertions.assertTrue(((RemoveUserEvent) firstEvent).isExists());
+    Event secondEvent = dummyEventListener.getPostEvents().get(1);
+    Assertions.assertEquals(RemoveUserFailureEvent.class, 
secondEvent.getClass());
+    Assertions.assertEquals(OperationStatus.FAILURE, 
secondEvent.operationStatus());
+    Assertions.assertEquals(inExistIdentifier, secondEvent.identifier());
+    Assertions.assertEquals(inExistUserName, ((RemoveUserFailureEvent) 
secondEvent).userName());
+  }
+
   @Test
   void testGetUserByExternalIdFailureEvent() {
     Assertions.assertThrowsExactly(
@@ -709,10 +769,22 @@ public class TestUserEvent {
     when(dispatcher.addUser(METALAKE, userName)).thenReturn(user);
     when(dispatcher.addUser(METALAKE, otherUserName)).thenReturn(otherUser);
     when(dispatcher.addUser(METALAKE, userName, USER_EXT_ID, 
true)).thenReturn(externalIdUser);
+    when(dispatcher.addUsers(eq(METALAKE), any()))
+        .thenReturn(
+            Arrays.asList(
+                BulkItemResult.success(0, userName, user),
+                BulkItemResult.failure(
+                    1, otherUserName, new GravitinoRuntimeException("Failed to 
add user"))));
 
     when(dispatcher.removeUser(METALAKE, userName)).thenReturn(true);
     when(dispatcher.removeUser(METALAKE, inExistUserName)).thenReturn(false);
     when(dispatcher.removeUserByExternalId(METALAKE, 
USER_EXT_ID)).thenReturn(true);
+    when(dispatcher.removeUsers(eq(METALAKE), any(), any()))
+        .thenReturn(
+            Arrays.asList(
+                BulkItemResult.success(0, userName),
+                BulkItemResult.failure(
+                    1, inExistUserName, new NoSuchUserException("user not 
found"))));
 
     when(dispatcher.listUsers(METALAKE)).thenReturn(new User[] {user, 
otherUser});
     when(dispatcher.listUsers(eq(METALAKE), eq(0), eq(10)))
diff --git a/dev/docker/gravitino/rewrite_gravitino_server_config.py 
b/dev/docker/gravitino/rewrite_gravitino_server_config.py
index 6e27611e5a..c5aca5d72c 100755
--- a/dev/docker/gravitino/rewrite_gravitino_server_config.py
+++ b/dev/docker/gravitino/rewrite_gravitino_server_config.py
@@ -28,6 +28,7 @@ env_map = {
     "GRAVITINO_SERVER_WEBSERVER_THREAD_POOL_WORK_QUEUE_SIZE": 
"server.webserver.threadPoolWorkQueueSize",
     "GRAVITINO_SERVER_WEBSERVER_REQUEST_HEADER_SIZE": 
"server.webserver.requestHeaderSize",
     "GRAVITINO_SERVER_WEBSERVER_RESPONSE_HEADER_SIZE": 
"server.webserver.responseHeaderSize",
+    "GRAVITINO_SERVER_BULK_MAX_ITEMS": "server.bulk.maxItems",
     "GRAVITINO_ENTITY_STORE": "entity.store",
     "GRAVITINO_ENTITY_STORE_RELATIONAL": "entity.store.relational",
     "GRAVITINO_ENTITY_STORE_RELATIONAL_JDBC_URL": 
"entity.store.relational.jdbcUrl",
@@ -89,6 +90,7 @@ init_config = {
     "server.webserver.threadPoolWorkQueueSize": "100",
     "server.webserver.requestHeaderSize": "131072",
     "server.webserver.responseHeaderSize": "131072",
+    "server.bulk.maxItems": "100",
     "entity.store": "relational",
     "entity.store.relational": "JDBCBackend",
     "entity.store.relational.jdbcUrl": "jdbc:h2",
@@ -142,4 +144,4 @@ if os.path.exists(config_file_path):
 with open(config_file_path, "w") as file:
     for key, value in config_map.items():
         line = "{} = {}\n".format(key, value)
-        file.write(line)
\ No newline at end of file
+        file.write(line)
diff --git a/docs/gravitino-server-config.md b/docs/gravitino-server-config.md
index 963774a51b..c8d5ba2f10 100644
--- a/docs/gravitino-server-config.md
+++ b/docs/gravitino-server-config.md
@@ -172,6 +172,7 @@ empty string or list; `(none)` means it has no default at 
all.
 | `gravitino.server.webserver.customFilters`           | Comma-separated list 
of servlet filter class names to apply to the API.                              
                                                                                
        | (empty)                                 |
 | `gravitino.server.rest.extensionPackages`            | Comma-separated list 
of packages to scan for additional REST resources.                              
                                                                                
        | (empty)                                 |
 | `gravitino.server.visibleConfigs`                    | Comma-separated list 
of extra properties to expose on the unauthenticated `GET /configs` endpoint, 
on top of the fixed set it always returns. Additive, so each entry widens what 
is public. | (empty)                                 |
+| `gravitino.server.bulk.maxItems`                     | Maximum number of 
items allowed in a single bulk request.                                         
                                                                                
           | `100`                                   |
 
 Filters named in `customFilters` must be standard `javax.servlet` filters. 
Pass parameters to a
 filter with properties of the form
@@ -613,6 +614,7 @@ means the property is left alone.
 | `GRAVITINO_SERVER_WEBSERVER_THREAD_POOL_WORK_QUEUE_SIZE` | 
`gravitino.server.webserver.threadPoolWorkQueueSize` | `100`                    
                            |
 | `GRAVITINO_SERVER_WEBSERVER_REQUEST_HEADER_SIZE`         | 
`gravitino.server.webserver.requestHeaderSize`       | `131072`                 
                            |
 | `GRAVITINO_SERVER_WEBSERVER_RESPONSE_HEADER_SIZE`        | 
`gravitino.server.webserver.responseHeaderSize`      | `131072`                 
                            |
+| `GRAVITINO_SERVER_BULK_MAX_ITEMS`                        | 
`gravitino.server.bulk.maxItems`                     | `100`                    
                            |
 | `GRAVITINO_ENTITY_STORE`                                 | 
`gravitino.entity.store`                             | `relational`             
                            |
 | `GRAVITINO_ENTITY_STORE_RELATIONAL`                      | 
`gravitino.entity.store.relational`                  | `JDBCBackend`            
                            |
 | `GRAVITINO_ENTITY_STORE_RELATIONAL_JDBC_URL`             | 
`gravitino.entity.store.relational.jdbcUrl`          | `jdbc:h2`                
                            |
diff --git a/docs/open-api/bulk.yaml b/docs/open-api/bulk.yaml
new file mode 100644
index 0000000000..7377d39d21
--- /dev/null
+++ b/docs/open-api/bulk.yaml
@@ -0,0 +1,275 @@
+# 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.
+
+---
+
+paths:
+
+  /bulk/metalakes/{metalake}/users/add:
+    parameters:
+      - $ref: "./openapi.yaml#/components/parameters/metalake"
+
+    post:
+      tags:
+        - access control
+      summary: Add users in bulk
+      operationId: bulkAddUsers
+      description: Adds users 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/BulkUserAddRequest"
+            examples:
+              BulkUserAddRequest:
+                $ref: "#/components/examples/BulkUserAddRequest"
+      responses:
+        "200":
+          description: Returns successfully added users and item-level errors
+          content:
+            application/vnd.gravitino.v1+json:
+              schema:
+                $ref: "#/components/schemas/BulkUserResponse"
+              examples:
+                BulkUserResponse:
+                  $ref: "#/components/examples/BulkUserResponse"
+        "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}/users/remove:
+    parameters:
+      - $ref: "./openapi.yaml#/components/parameters/metalake"
+
+    post:
+      tags:
+        - access control
+      summary: Remove users in bulk
+      operationId: bulkRemoveUsers
+      description: Removes users in best-effort mode. Failed items are 
returned in the top-level errors array. User 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/BulkRemoveRequest"
+      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/BulkRemoveResponse"
+        "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:
+    BulkUserAddRequest:
+      type: object
+      required:
+        - users
+      properties:
+        users:
+          type: array
+          minItems: 1
+          description: The maximum length is controlled by 
`gravitino.server.bulk.maxItems`, which defaults to 100.
+          items:
+            $ref: "./users.yaml#/components/schemas/UserAddRequest"
+
+    BulkRemoveRequest:
+      type: object
+      required:
+        - names
+      properties:
+        names:
+          type: array
+          minItems: 1
+          description: The maximum length is controlled by 
`gravitino.server.bulk.maxItems`, which defaults to 100.
+          items:
+            type: string
+
+    BulkError:
+      type: object
+      required:
+        - index
+        - code
+        - type
+        - message
+      properties:
+        index:
+          type: integer
+          format: int32
+          description: The zero-based index of the failed request item
+        name:
+          type: string
+          description: The name of the failed request item
+        code:
+          type: integer
+          format: int32
+          description: Gravitino error code of the failed item
+        type:
+          type: string
+          description: Error type of the failed item
+        message:
+          type: string
+          description: Error message of the failed item
+
+    BulkSummary:
+      type: object
+      required:
+        - total
+        - succeeded
+        - failed
+      properties:
+        total:
+          type: integer
+          format: int32
+        succeeded:
+          type: integer
+          format: int32
+        failed:
+          type: integer
+          format: int32
+
+    BulkUserResponse:
+      type: object
+      properties:
+        code:
+          type: integer
+          format: int32
+          description: Status code of the response
+          enum:
+            - 0
+        users:
+          type: array
+          description: Successfully added users
+          items:
+            $ref: "./users.yaml#/components/schemas/User"
+        errors:
+          type: array
+          description: Item-level errors
+          items:
+            $ref: "#/components/schemas/BulkError"
+        summary:
+          $ref: "#/components/schemas/BulkSummary"
+
+    BulkRemoveResponse:
+      type: object
+      properties:
+        code:
+          type: integer
+          format: int32
+          description: Status code of the response
+          enum:
+            - 0
+        names:
+          type: array
+          description: Successfully removed names
+          items:
+            type: string
+        errors:
+          type: array
+          description: Item-level errors
+          items:
+            $ref: "#/components/schemas/BulkError"
+        summary:
+          $ref: "#/components/schemas/BulkSummary"
+
+  examples:
+    BulkUserAddRequest:
+      value: {
+        "users": [
+          {"name": "alice", "externalId": "ext-alice", "enabled": true},
+          {"name": "bob", "enabled": true}
+        ]
+      }
+
+    BulkRemoveRequest:
+      value: {
+        "names": ["alice", "bob", "ghost"]
+      }
+
+    BulkUserResponse:
+      value: {
+        "code": 0,
+        "users": [
+          {
+            "name": "alice",
+            "externalId": "ext-alice",
+            "enabled": true,
+            "roles": [],
+            "audit": {
+              "creator": "gravitino",
+              "createTime": "2026-07-28T10:00:00Z"
+            }
+          }
+        ],
+        "errors": [
+          {
+            "index": 1,
+            "name": "bob",
+            "code": 1004,
+            "type": "UserAlreadyExistsException",
+            "message": "User already exists: bob"
+          }
+        ],
+        "summary": {"total": 2, "succeeded": 1, "failed": 1}
+      }
+
+    BulkRemoveResponse:
+      value: {
+        "code": 0,
+        "names": ["alice", "bob"],
+        "errors": [
+          {
+            "index": 2,
+            "name": "ghost",
+            "code": 1003,
+            "type": "NoSuchUserException",
+            "message": "User 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 ac73f3fa0c..76d32c69fb 100644
--- a/docs/open-api/openapi.yaml
+++ b/docs/open-api/openapi.yaml
@@ -201,6 +201,12 @@ paths:
   /metalakes/{metalake}/roles/{role}:
     $ref: "./roles.yaml#/paths/~1metalakes~1%7Bmetalake%7D~1roles~1%7Brole%7D"
 
+  /bulk/metalakes/{metalake}/users/add:
+    $ref: "./bulk.yaml#/paths/~1bulk~1metalakes~1%7Bmetalake%7D~1users~1add"
+
+  /bulk/metalakes/{metalake}/users/remove:
+    $ref: "./bulk.yaml#/paths/~1bulk~1metalakes~1%7Bmetalake%7D~1users~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 6883089813..969d8a359c 100755
--- a/docs/security/access-control.md
+++ b/docs/security/access-control.md
@@ -270,6 +270,42 @@ 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`.
+
+| 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` |
+
+For example, add users in bulk:
+
+```shell
+curl -X POST "http://localhost:8090/api/bulk/metalakes/{metalake}/users/add"; \
+  -H "Authorization: Bearer $MANAGER_TOKEN" \
+  -H "Accept: application/vnd.gravitino.v1+json" \
+  -H "Content-Type: application/json" \
+  -d '{
+  "users": [
+    {"name": "analyst"},
+    {"name": "developer", "externalId": "[email protected]", "enabled": 
true}
+  ]
+}'
+```
+
+Remove users in bulk:
+
+```shell
+curl -X POST 
"http://localhost:8090/api/bulk/metalakes/{metalake}/users/remove"; \
+  -H "Authorization: Bearer $MANAGER_TOKEN" \
+  -H "Accept: application/vnd.gravitino.v1+json" \
+  -H "Content-Type: application/json" \
+  -d '{
+  "names": ["analyst", "developer"]
+}'
+```
+
 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/filter/GravitinoInterceptionService.java
 
b/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
index e3e057ffc4..c23818cc17 100644
--- 
a/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
@@ -54,6 +54,7 @@ import 
org.apache.gravitino.server.authorization.annotations.ExpressionCondition
 import org.apache.gravitino.server.web.Utils;
 import 
org.apache.gravitino.server.web.filter.authorization.AuthorizationExecutor;
 import 
org.apache.gravitino.server.web.filter.authorization.AuthorizeExecutorFactory;
+import org.apache.gravitino.server.web.rest.BulkOperations;
 import org.apache.gravitino.server.web.rest.CatalogOperations;
 import org.apache.gravitino.server.web.rest.FilesetOperations;
 import org.apache.gravitino.server.web.rest.FunctionOperations;
@@ -101,6 +102,7 @@ public class GravitinoInterceptionService implements 
InterceptionService {
             FunctionOperations.class.getName(),
             TopicOperations.class.getName(),
             FilesetOperations.class.getName(),
+            BulkOperations.class.getName(),
             UserOperations.class.getName(),
             GroupOperations.class.getName(),
             PermissionOperations.class.getName(),
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
new file mode 100644
index 0000000000..0cd73dd5fe
--- /dev/null
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/rest/BulkOperations.java
@@ -0,0 +1,181 @@
+/*
+ * 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.server.web.rest;
+
+import com.codahale.metrics.annotation.ResponseMetered;
+import com.codahale.metrics.annotation.Timed;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.Response;
+import org.apache.gravitino.Entity;
+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.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.UserAdd;
+import org.apache.gravitino.dto.authorization.UserDTO;
+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.BulkRemoveResponse;
+import org.apache.gravitino.dto.responses.BulkSummary;
+import org.apache.gravitino.dto.responses.BulkUserResponse;
+import org.apache.gravitino.dto.util.DTOConverters;
+import org.apache.gravitino.metalake.MetalakeManager;
+import org.apache.gravitino.metrics.MetricNames;
+import org.apache.gravitino.server.authorization.NameBindings;
+import 
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
+import 
org.apache.gravitino.server.authorization.annotations.AuthorizationMetadata;
+import org.apache.gravitino.server.web.Utils;
+
+/** Provides best-effort bulk APIs for metalake access-control entities. */
[email protected]
+@Path("/bulk/metalakes/{metalake}")
+public class BulkOperations {
+
+  private static final String USERS_FIELD_NAME = "users";
+  private static final String NAMES_FIELD_NAME = "names";
+
+  private final BulkManager bulkManager;
+  private final AccessControlDispatcher accessControlDispatcher;
+  private final OwnerDispatcher ownerDispatcher;
+
+  @Context private HttpServletRequest httpRequest;
+
+  /** Creates a new bulk operations resource. */
+  public BulkOperations() {
+    this.bulkManager = GravitinoEnv.getInstance().bulkManager();
+    this.accessControlDispatcher = 
GravitinoEnv.getInstance().accessControlDispatcher();
+    this.ownerDispatcher = GravitinoEnv.getInstance().ownerDispatcher();
+  }
+
+  /**
+   * Adds users in bulk.
+   *
+   * @param metalake The metalake name.
+   * @param request The bulk user add request.
+   * @return The bulk user response.
+   */
+  @POST
+  @Path("users/add")
+  @Produces("application/vnd.gravitino.v1+json")
+  @Timed(name = "bulk-add-user." + MetricNames.HTTP_PROCESS_DURATION, absolute 
= true)
+  @ResponseMetered(name = "bulk-add-user", absolute = true)
+  @AuthorizationExpression(expression = "METALAKE::OWNER || 
METALAKE::MANAGE_USERS")
+  public Response addUsers(
+      @PathParam("metalake") @AuthorizationMetadata(type = 
Entity.EntityType.METALAKE)
+          String metalake,
+      BulkUserAddRequest request) {
+    try {
+      return Utils.doAs(
+          httpRequest,
+          () -> {
+            request.validate();
+            bulkManager.checkBulkSize(USERS_FIELD_NAME, 
request.getUsers().length);
+            MetalakeManager.checkMetalakeInUse(metalake);
+            List<BulkItemResult<User>> results =
+                accessControlDispatcher.addUsers(
+                    metalake,
+                    Arrays.stream(request.getUsers())
+                        .map(
+                            user ->
+                                new UserAdd(
+                                    user.getName(), user.getExternalId(), 
user.getEnabled()))
+                        .collect(Collectors.toList()));
+            UserDTO[] users =
+                results.stream()
+                    .filter(BulkItemResult::succeeded)
+                    .map(result -> DTOConverters.toDTO(result.value().get()))
+                    .toArray(UserDTO[]::new);
+            BulkError[] errors =
+                results.stream()
+                    .filter(result -> !result.succeeded())
+                    .map(bulkManager::toBulkError)
+                    .toArray(BulkError[]::new);
+            return Utils.ok(
+                new BulkUserResponse(
+                    users, errors, new BulkSummary(results.size(), 
users.length, errors.length)));
+          });
+    } catch (Exception e) {
+      return ExceptionHandlers.handleUserException(OperationType.ADD, "", 
metalake, e);
+    }
+  }
+
+  /**
+   * Removes users in bulk.
+   *
+   * @param metalake The metalake name.
+   * @param request The bulk remove request.
+   * @return The bulk remove response.
+   */
+  @POST
+  @Path("users/remove")
+  @Produces("application/vnd.gravitino.v1+json")
+  @Timed(name = "bulk-remove-user." + MetricNames.HTTP_PROCESS_DURATION, 
absolute = true)
+  @ResponseMetered(name = "bulk-remove-user", absolute = true)
+  @AuthorizationExpression(expression = "METALAKE::OWNER || 
METALAKE::MANAGE_USERS")
+  public Response removeUsers(
+      @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.removeUsers(
+                    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.handleUserException(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
new file mode 100644
index 0000000000..b55f599758
--- /dev/null
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestBulkOperations.java
@@ -0,0 +1,253 @@
+/*
+ * 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.server.web.rest;
+
+import static org.apache.gravitino.Configs.TREE_LOCK_CLEAN_INTERVAL;
+import static org.apache.gravitino.Configs.TREE_LOCK_MAX_NODE_IN_MEMORY;
+import static org.apache.gravitino.Configs.TREE_LOCK_MIN_NODE_IN_MEMORY;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.RETURNS_DEFAULTS;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.client.Entity;
+import javax.ws.rs.core.Application;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import org.apache.commons.lang3.reflect.FieldUtils;
+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.OwnerDispatcher;
+import org.apache.gravitino.authorization.User;
+import org.apache.gravitino.bulk.BulkItemResult;
+import org.apache.gravitino.bulk.BulkManager;
+import org.apache.gravitino.bulk.UserAdd;
+import org.apache.gravitino.config.ConfigEntry;
+import org.apache.gravitino.connector.PropertiesMetadata;
+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.dto.responses.ErrorConstants;
+import org.apache.gravitino.dto.responses.ErrorResponse;
+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.UserEntity;
+import org.apache.gravitino.rest.RESTUtils;
+import org.glassfish.hk2.utilities.binding.AbstractBinder;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.TestProperties;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+
+public class TestBulkOperations extends BaseOperationsTest {
+
+  private static final AccessControlManager manager = 
mock(AccessControlManager.class);
+  private static final EntityStore entityStore = mock(EntityStore.class);
+  private static final OwnerDispatcher ownerDispatcher = 
mock(OwnerDispatcher.class);
+  private static BulkOperations bulkOperations;
+
+  private static class MockServletRequestFactory extends 
ServletRequestFactoryBase {
+    @Override
+    public HttpServletRequest get() {
+      HttpServletRequest request = mock(HttpServletRequest.class);
+      when(request.getRemoteUser()).thenReturn(null);
+      return request;
+    }
+  }
+
+  @BeforeAll
+  public static void setup() throws IllegalAccessException {
+    Config config =
+        mock(
+            Config.class,
+            invocation -> {
+              if ("get".equals(invocation.getMethod().getName())
+                  && invocation.getArguments().length == 1
+                  && invocation.getArgument(0) instanceof ConfigEntry) {
+                ConfigEntry<?> entry = invocation.getArgument(0);
+                return entry.getDefaultValue();
+              }
+              return RETURNS_DEFAULTS.answer(invocation);
+            });
+    doReturn(100000L).when(config).get(TREE_LOCK_MAX_NODE_IN_MEMORY);
+    doReturn(1000L).when(config).get(TREE_LOCK_MIN_NODE_IN_MEMORY);
+    doReturn(36000L).when(config).get(TREE_LOCK_CLEAN_INTERVAL);
+    doReturn(2).when(config).get(org.apache.gravitino.Configs.BULK_MAX_ITEMS);
+    FieldUtils.writeField(GravitinoEnv.getInstance(), "config", config, true);
+    FieldUtils.writeField(GravitinoEnv.getInstance(), "lockManager", new 
LockManager(config), true);
+    FieldUtils.writeField(GravitinoEnv.getInstance(), 
"accessControlDispatcher", manager, true);
+    FieldUtils.writeField(GravitinoEnv.getInstance(), "ownerDispatcher", 
ownerDispatcher, true);
+    FieldUtils.writeField(GravitinoEnv.getInstance(), "bulkManager", new 
BulkManager(config), true);
+    FieldUtils.writeField(GravitinoEnv.getInstance(), "entityStore", 
entityStore, true);
+    bulkOperations = new BulkOperations();
+  }
+
+  @BeforeEach
+  public void resetMocks() throws IOException {
+    Mockito.reset(manager, entityStore, ownerDispatcher);
+    BaseMetalake metalake = mock(BaseMetalake.class);
+    PropertiesMetadata propertiesMetadata = mock(PropertiesMetadata.class);
+    when(propertiesMetadata.getOrDefault(any(), any())).thenReturn(true);
+    when(metalake.propertiesMetadata()).thenReturn(propertiesMetadata);
+    when(entityStore.get(any(), any(), any())).thenReturn(metalake);
+    when(ownerDispatcher.getOwner(any(), any())).thenReturn(Optional.empty());
+  }
+
+  @Override
+  protected Application configure() {
+    try {
+      forceSet(
+          TestProperties.CONTAINER_PORT, 
String.valueOf(RESTUtils.findAvailablePort(2000, 3000)));
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+
+    ResourceConfig resourceConfig = new ResourceConfig();
+    resourceConfig.register(bulkOperations);
+    resourceConfig.register(
+        new AbstractBinder() {
+          @Override
+          protected void configure() {
+            
bindFactory(MockServletRequestFactory.class).to(HttpServletRequest.class);
+          }
+        });
+
+    return resourceConfig;
+  }
+
+  @Test
+  public void testBulkAddUsersBestEffort() {
+    User user1 = buildUser("user1");
+    when(manager.addUsers(any(), any()))
+        .thenReturn(
+            Arrays.asList(
+                BulkItemResult.success(0, "user1", user1),
+                BulkItemResult.failure(
+                    1, "user2", new UserAlreadyExistsException("User already 
exists: user2"))));
+
+    BulkUserAddRequest request =
+        new BulkUserAddRequest(
+            new UserAddRequest[] {
+              new UserAddRequest("user1", "ext-user1", false), new 
UserAddRequest("user2")
+            });
+    Response response =
+        target("/bulk/metalakes/metalake1/users/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());
+    BulkUserResponse bulkResponse = 
response.readEntity(BulkUserResponse.class);
+    Assertions.assertEquals(1, bulkResponse.getUsers().length);
+    Assertions.assertEquals("user1", bulkResponse.getUsers()[0].name());
+    Assertions.assertEquals(1, bulkResponse.getErrors().length);
+    Assertions.assertEquals(1, bulkResponse.getErrors()[0].getIndex());
+    Assertions.assertEquals("user2", 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<UserAdd>> usersCaptor = 
ArgumentCaptor.forClass(List.class);
+    Mockito.verify(manager).addUsers(eq("metalake1"), usersCaptor.capture());
+    Assertions.assertEquals("user1", usersCaptor.getValue().get(0).name());
+    Assertions.assertEquals("ext-user1", 
usersCaptor.getValue().get(0).externalId());
+    Assertions.assertEquals(false, usersCaptor.getValue().get(0).enabled());
+  }
+
+  @Test
+  public void testBulkRemoveUsersBestEffort() {
+    when(manager.removeUsers(any(), any(), any()))
+        .thenReturn(
+            Arrays.asList(
+                BulkItemResult.success(0, "user1"),
+                BulkItemResult.failure(
+                    1, "ghost", new NoSuchUserException("User does not exist: 
ghost"))));
+
+    BulkRemoveRequest request = new BulkRemoveRequest(new String[] {"user1", 
"ghost"});
+    Response response =
+        target("/bulk/metalakes/metalake1/users/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[] {"user1"}, 
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 =
+        target("/bulk/metalakes/metalake1/users/add")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .post(
+                Entity.entity(
+                    new BulkUserAddRequest(new UserAddRequest[] {}),
+                    MediaType.APPLICATION_JSON_TYPE));
+    Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), 
emptyResponse.getStatus());
+
+    BulkRemoveRequest exceededRequest =
+        new BulkRemoveRequest(new String[] {"user1", "user2", "user3"});
+    Response exceededResponse =
+        target("/bulk/metalakes/metalake1/users/remove")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .post(Entity.entity(exceededRequest, 
MediaType.APPLICATION_JSON_TYPE));
+    Assertions.assertEquals(
+        Response.Status.BAD_REQUEST.getStatusCode(), 
exceededResponse.getStatus());
+    ErrorResponse errorResponse = 
exceededResponse.readEntity(ErrorResponse.class);
+    Assertions.assertEquals(ErrorConstants.ILLEGAL_ARGUMENTS_CODE, 
errorResponse.getCode());
+  }
+
+  private User buildUser(String user) {
+    return UserEntity.builder()
+        .withId(1L)
+        .withName(user)
+        .withRoleNames(Collections.emptyList())
+        .withAuditInfo(
+            
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build())
+        .build();
+  }
+}

Reply via email to