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 f451824952 [#12287] feat(server): Add bulk role access-control APIs 
(#12732)
f451824952 is described below

commit f45182495250914261b0e2f50380aa1431206d35
Author: jarred0214 <[email protected]>
AuthorDate: Tue Sep 8 19:07:49 2026 +0800

    [#12287] feat(server): Add bulk role access-control APIs (#12732)
    
    ### What changes were proposed in this pull request?
    
    This PR adds best-effort bulk role access-control APIs as the third part
    of #12287.
    
    Changes include:
    - Add bulk role add request/response DTOs.
    - Add a core `RoleAdd` bulk item model.
    - Add `AccessControlDispatcher#createRoles` and `#deleteRoles`.
    - Implement bulk role create/delete logic in `AccessControlManager` with
    item-level results.
    - Dispatch existing per-role create/delete pre, success, and failure
    events for bulk role operations.
    - Add REST APIs for bulk role add/remove.
    - Add OpenAPI definitions for bulk role APIs.
    - Add tests for best-effort behavior, request validation, role
    authorization, and core manager behavior.
    
    ### Why are the changes needed?
    
    #12287 tracks best-effort bulk operations for access-control entities
    under a metalake. The first PR added the shared bulk foundation and bulk
    user APIs, and the second PR added bulk group APIs. This PR continues
    the epic by adding bulk role APIs using the same approach.
    
    Part of #12287.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes.
    
    New REST APIs:
    - `POST /api/bulk/metalakes/{metalake}/roles/add`
    - `POST /api/bulk/metalakes/{metalake}/roles/remove`
    
    The APIs use the existing `gravitino.server.bulk.maxItems` limit
    introduced by the bulk foundation PR.
    
    ### How was this patch tested?
    
    -
    
`JAVA_HOME=/opt/homebrew/Cellar/openjdk@17/17.0.18/libexec/openjdk.jdk/Contents/Home
    ./gradlew :common:compileJava :core:compileTestJava
    :server:compileTestJava :clients:client-java:compileTestJava`
    -
    
`JAVA_HOME=/opt/homebrew/Cellar/openjdk@17/17.0.18/libexec/openjdk.jdk/Contents/Home
    ./gradlew :core:test --tests
    org.apache.gravitino.authorization.TestAccessControlManager :server:test
    --tests org.apache.gravitino.server.web.rest.TestBulkOperations
    :docs:build -PskipITs`
    -
    
`JAVA_HOME=/opt/homebrew/Cellar/openjdk@17/17.0.18/libexec/openjdk.jdk/Contents/Home
    ./gradlew :server:test --tests
    org.apache.gravitino.server.web.rest.TestBulkOperations -PskipITs`
    - `git diff --check`
---
 .../test/authorization/RoleAuthorizationIT.java    | 103 +++++++++
 .../gravitino/dto/requests/BulkRoleAddRequest.java |  62 ++++++
 .../gravitino/dto/responses/BulkRoleResponse.java  |  72 +++++++
 .../dto/requests/TestBulkRoleAddRequest.java       |  70 +++++++
 .../dto/responses/TestBulkRoleResponse.java        |  79 +++++++
 .../authorization/AccessControlDispatcher.java     |  25 +++
 .../authorization/AccessControlManager.java        |  51 +++++
 .../java/org/apache/gravitino/bulk/RoleAdd.java    |  76 +++++++
 .../hook/AccessControlHookDispatcher.java          |  48 +++++
 .../listener/AccessControlEventDispatcher.java     |  76 +++++++
 .../authorization/TestAccessControlManager.java    |  40 ++++
 .../listener/api/event/TestRoleEvent.java          |  34 +++
 docs/open-api/bulk.yaml                            | 169 +++++++++++++++
 docs/open-api/openapi.yaml                         |   6 +
 docs/security/access-control.md                    |  50 ++++-
 .../gravitino/server/web/rest/BulkOperations.java  | 233 +++++++++++++++++++++
 .../server/web/rest/TestBulkOperations.java        |  97 +++++++++
 17 files changed, 1281 insertions(+), 10 deletions(-)

diff --git 
a/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/RoleAuthorizationIT.java
 
b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/RoleAuthorizationIT.java
index 536fe9d928..b84a3e673d 100644
--- 
a/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/RoleAuthorizationIT.java
+++ 
b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/RoleAuthorizationIT.java
@@ -19,6 +19,7 @@ package 
org.apache.gravitino.client.integration.test.authorization;
 
 import static org.junit.Assert.assertArrayEquals;
 import static org.junit.Assert.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertEquals;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -30,9 +31,20 @@ import java.util.HashMap;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.MetadataObjects;
 import org.apache.gravitino.authorization.Privileges;
+import org.apache.gravitino.client.ErrorHandlers;
+import org.apache.gravitino.client.GravitinoAdminClient;
 import org.apache.gravitino.client.GravitinoMetalake;
+import org.apache.gravitino.client.RESTClient;
 import org.apache.gravitino.dto.MetalakeDTO;
+import org.apache.gravitino.dto.authorization.SecurableObjectDTO;
+import org.apache.gravitino.dto.requests.BulkRemoveRequest;
+import org.apache.gravitino.dto.requests.BulkRoleAddRequest;
+import org.apache.gravitino.dto.requests.RoleCreateRequest;
+import org.apache.gravitino.dto.responses.BulkRemoveResponse;
+import org.apache.gravitino.dto.responses.BulkRoleResponse;
+import org.apache.gravitino.dto.responses.ErrorConstants;
 import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.gravitino.rest.RESTUtils;
 import org.junit.jupiter.api.MethodOrderer;
 import org.junit.jupiter.api.Order;
 import org.junit.jupiter.api.Tag;
@@ -158,6 +170,66 @@ public class RoleAuthorizationIT extends 
BaseRestApiAuthorizationIT {
 
   @Test
   @Order(5)
+  public void testBulkRoleInterfaces() throws Exception {
+    String bulkRoleManager = "bulk_role_manager";
+    String bulkRole1 = "bulk_role_it_1";
+    String bulkRole2 = "bulk_role_it_2";
+    String adminRole = "bulk_role_admin";
+    GravitinoMetalake gravitinoMetalake = client.loadMetalake(METALAKE);
+    gravitinoMetalake.addUser(bulkRoleManager);
+    gravitinoMetalake.createRole(adminRole, new HashMap<>(), 
Collections.emptyList());
+
+    assertThrows(
+        ForbiddenException.class,
+        () ->
+            bulkAddRoles(
+                restClient(normalUserClient),
+                new BulkRoleAddRequest(
+                    new RoleCreateRequest[] {
+                      new RoleCreateRequest(bulkRole1, new HashMap<>(), new 
SecurableObjectDTO[] {})
+                    })));
+
+    BulkRemoveResponse unauthorizedRemoveResponse =
+        bulkRemoveRoles(
+            restClient(normalUserClient), new BulkRemoveRequest(new String[] 
{adminRole}));
+    assertEquals(0, unauthorizedRemoveResponse.getNames().length);
+    assertEquals(1, unauthorizedRemoveResponse.getErrors().length);
+    assertEquals(
+        ErrorConstants.FORBIDDEN_CODE, 
unauthorizedRemoveResponse.getErrors()[0].getCode());
+
+    gravitinoMetalake.createRole(
+        "bulk_role_create_grant", new HashMap<>(), Collections.emptyList());
+    gravitinoMetalake.grantPrivilegesToRole(
+        "bulk_role_create_grant",
+        MetadataObjects.of(null, METALAKE, MetadataObject.Type.METALAKE),
+        ImmutableList.of(Privileges.CreateRole.allow()));
+    
gravitinoMetalake.grantRolesToUser(ImmutableList.of("bulk_role_create_grant"), 
bulkRoleManager);
+
+    GravitinoAdminClient bulkRoleManagerClient = 
getClientByUser(bulkRoleManager);
+    BulkRoleResponse addResponse =
+        bulkAddRoles(
+            restClient(bulkRoleManagerClient),
+            new BulkRoleAddRequest(
+                new RoleCreateRequest[] {
+                  new RoleCreateRequest(bulkRole1, new HashMap<>(), new 
SecurableObjectDTO[] {}),
+                  new RoleCreateRequest(bulkRole2, new HashMap<>(), new 
SecurableObjectDTO[] {})
+                }));
+    assertEquals(2, addResponse.getRoles().length);
+    assertEquals(0, addResponse.getErrors().length);
+
+    BulkRemoveResponse removeResponse =
+        bulkRemoveRoles(
+            restClient(bulkRoleManagerClient),
+            new BulkRemoveRequest(new String[] {bulkRole1, bulkRole2}));
+    assertArrayEquals(new String[] {bulkRole1, bulkRole2}, 
removeResponse.getNames());
+    assertEquals(0, removeResponse.getErrors().length);
+
+    gravitinoMetalake.deleteRole(adminRole);
+    gravitinoMetalake.removeUser(bulkRoleManager);
+  }
+
+  @Test
+  @Order(6)
   public void testListRolesWithNonExistentMetalake() throws Exception {
     // Test that listRoles with @AuthorizationExpression returns 403 Forbidden
     // when the metalake doesn't exist, instead of 404 response
@@ -196,4 +268,35 @@ public class RoleAuthorizationIT extends 
BaseRestApiAuthorizationIT {
     // Test listRoleNames - should return 403 ForbiddenException
     assertThrows(ForbiddenException.class, 
nonExistentMetalakeObj::listRoleNames);
   }
+
+  private GravitinoAdminClient getClientByUser(String username) {
+    GravitinoAdminClient client =
+        
GravitinoAdminClient.builder(serverUri).withSimpleAuth(username).build();
+    closer.register(client);
+    return client;
+  }
+
+  private BulkRoleResponse bulkAddRoles(RESTClient restClient, 
BulkRoleAddRequest request) {
+    BulkRoleResponse response =
+        restClient.post(
+            String.format("api/bulk/metalakes/%s/roles/add", 
RESTUtils.encodeString(METALAKE)),
+            request,
+            BulkRoleResponse.class,
+            Collections.emptyMap(),
+            ErrorHandlers.roleErrorHandler());
+    response.validate();
+    return response;
+  }
+
+  private BulkRemoveResponse bulkRemoveRoles(RESTClient restClient, 
BulkRemoveRequest request) {
+    BulkRemoveResponse response =
+        restClient.post(
+            String.format("api/bulk/metalakes/%s/roles/remove", 
RESTUtils.encodeString(METALAKE)),
+            request,
+            BulkRemoveResponse.class,
+            Collections.emptyMap(),
+            ErrorHandlers.roleErrorHandler());
+    response.validate();
+    return response;
+  }
 }
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/requests/BulkRoleAddRequest.java
 
b/common/src/main/java/org/apache/gravitino/dto/requests/BulkRoleAddRequest.java
new file mode 100644
index 0000000000..b50bdc48b7
--- /dev/null
+++ 
b/common/src/main/java/org/apache/gravitino/dto/requests/BulkRoleAddRequest.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 roles in bulk. */
+@Getter
+@EqualsAndHashCode
+@ToString
+public class BulkRoleAddRequest implements RESTRequest {
+
+  @JsonProperty("roles")
+  private final RoleCreateRequest[] roles;
+
+  /**
+   * Creates a new BulkRoleAddRequest.
+   *
+   * @param roles The role create requests.
+   */
+  public BulkRoleAddRequest(RoleCreateRequest[] roles) {
+    this.roles = roles;
+  }
+
+  /** Default constructor for BulkRoleAddRequest. (Used for Jackson 
deserialization.) */
+  public BulkRoleAddRequest() {
+    this(null);
+  }
+
+  @Override
+  public void validate() throws IllegalArgumentException {
+    Preconditions.checkArgument(roles != null && roles.length > 0, "\"roles\" 
must not be empty");
+    Arrays.stream(roles)
+        .forEach(
+            role -> {
+              Preconditions.checkArgument(role != null, "role must not be 
null");
+              role.validate();
+            });
+  }
+}
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/responses/BulkRoleResponse.java 
b/common/src/main/java/org/apache/gravitino/dto/responses/BulkRoleResponse.java
new file mode 100644
index 0000000000..0dd9c0fb69
--- /dev/null
+++ 
b/common/src/main/java/org/apache/gravitino/dto/responses/BulkRoleResponse.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.RoleDTO;
+
+/** Represents a bulk role response. */
+@Getter
+@ToString
+@EqualsAndHashCode(callSuper = true)
+public class BulkRoleResponse extends BaseResponse {
+
+  @JsonProperty("roles")
+  private final RoleDTO[] roles;
+
+  @JsonProperty("errors")
+  private final BulkError[] errors;
+
+  @JsonProperty("summary")
+  private final BulkSummary summary;
+
+  /**
+   * Creates a new BulkRoleResponse.
+   *
+   * @param roles The successfully added roles.
+   * @param errors The item-level errors.
+   * @param summary The summary counts.
+   */
+  public BulkRoleResponse(RoleDTO[] roles, BulkError[] errors, BulkSummary 
summary) {
+    super(0);
+    this.roles = roles;
+    this.errors = errors;
+    this.summary = summary;
+  }
+
+  /** Default constructor for BulkRoleResponse. (Used for Jackson 
deserialization.) */
+  public BulkRoleResponse() {
+    this(null, null, null);
+  }
+
+  @Override
+  public void validate() throws IllegalArgumentException {
+    super.validate();
+    Preconditions.checkArgument(roles != null, "roles 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/test/java/org/apache/gravitino/dto/requests/TestBulkRoleAddRequest.java
 
b/common/src/test/java/org/apache/gravitino/dto/requests/TestBulkRoleAddRequest.java
new file mode 100644
index 0000000000..d972d86dfd
--- /dev/null
+++ 
b/common/src/test/java/org/apache/gravitino/dto/requests/TestBulkRoleAddRequest.java
@@ -0,0 +1,70 @@
+/*
+ * 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.core.JsonProcessingException;
+import com.google.common.collect.ImmutableMap;
+import org.apache.gravitino.dto.authorization.SecurableObjectDTO;
+import org.apache.gravitino.json.JsonUtils;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestBulkRoleAddRequest {
+
+  @Test
+  public void testBulkRoleAddRequestSerDe() throws JsonProcessingException {
+    BulkRoleAddRequest request =
+        new BulkRoleAddRequest(
+            new RoleCreateRequest[] {
+              new RoleCreateRequest(
+                  "role1", ImmutableMap.of("key1", "value1"), new 
SecurableObjectDTO[] {}),
+              new RoleCreateRequest("role2", null, new SecurableObjectDTO[] {})
+            });
+
+    String serJson = JsonUtils.objectMapper().writeValueAsString(request);
+    BulkRoleAddRequest deserRequest =
+        JsonUtils.objectMapper().readValue(serJson, BulkRoleAddRequest.class);
+
+    Assertions.assertEquals(request, deserRequest);
+    Assertions.assertEquals(2, deserRequest.getRoles().length);
+    Assertions.assertEquals("role1", deserRequest.getRoles()[0].getName());
+    Assertions.assertEquals(
+        ImmutableMap.of("key1", "value1"), 
deserRequest.getRoles()[0].getProperties());
+    Assertions.assertDoesNotThrow(deserRequest::validate);
+  }
+
+  @Test
+  public void testBulkRoleAddRequestValidate() {
+    Assertions.assertThrows(IllegalArgumentException.class, new 
BulkRoleAddRequest()::validate);
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> new BulkRoleAddRequest(new RoleCreateRequest[] {}).validate());
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> new BulkRoleAddRequest(new RoleCreateRequest[] 
{null}).validate());
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            new BulkRoleAddRequest(
+                    new RoleCreateRequest[] {
+                      new RoleCreateRequest("", null, new SecurableObjectDTO[] 
{})
+                    })
+                .validate());
+  }
+}
diff --git 
a/common/src/test/java/org/apache/gravitino/dto/responses/TestBulkRoleResponse.java
 
b/common/src/test/java/org/apache/gravitino/dto/responses/TestBulkRoleResponse.java
new file mode 100644
index 0000000000..e5b224a306
--- /dev/null
+++ 
b/common/src/test/java/org/apache/gravitino/dto/responses/TestBulkRoleResponse.java
@@ -0,0 +1,79 @@
+/*
+ * 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.core.JsonProcessingException;
+import com.google.common.collect.ImmutableMap;
+import java.time.Instant;
+import org.apache.gravitino.dto.AuditDTO;
+import org.apache.gravitino.dto.authorization.RoleDTO;
+import org.apache.gravitino.dto.authorization.SecurableObjectDTO;
+import org.apache.gravitino.json.JsonUtils;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestBulkRoleResponse {
+
+  @Test
+  public void testBulkRoleResponseSerDe() throws JsonProcessingException {
+    RoleDTO role =
+        RoleDTO.builder()
+            .withName("role1")
+            .withProperties(ImmutableMap.of("key1", "value1"))
+            .withSecurableObjects(new SecurableObjectDTO[] {})
+            .withAudit(
+                
AuditDTO.builder().withCreator("creator").withCreateTime(Instant.now()).build())
+            .build();
+    BulkError error =
+        new BulkError(
+            1,
+            "role2",
+            ErrorConstants.ALREADY_EXISTS_CODE,
+            "RoleAlreadyExistsException",
+            "Role already exists: role2");
+    BulkRoleResponse response =
+        new BulkRoleResponse(
+            new RoleDTO[] {role}, new BulkError[] {error}, new BulkSummary(2, 
1, 1));
+
+    String serJson = JsonUtils.objectMapper().writeValueAsString(response);
+    BulkRoleResponse deserResponse =
+        JsonUtils.objectMapper().readValue(serJson, BulkRoleResponse.class);
+
+    Assertions.assertEquals(response.getCode(), deserResponse.getCode());
+    Assertions.assertEquals(1, deserResponse.getRoles().length);
+    Assertions.assertEquals("role1", deserResponse.getRoles()[0].name());
+    Assertions.assertEquals(1, deserResponse.getErrors().length);
+    Assertions.assertEquals("role2", deserResponse.getErrors()[0].getName());
+    Assertions.assertDoesNotThrow(deserResponse::validate);
+  }
+
+  @Test
+  public void testBulkRoleResponseValidate() {
+    Assertions.assertThrows(IllegalArgumentException.class, new 
BulkRoleResponse()::validate);
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> new BulkRoleResponse(null, new BulkError[] {}, new 
BulkSummary(0, 0, 0)).validate());
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> new BulkRoleResponse(new RoleDTO[] {}, null, new BulkSummary(0, 
0, 0)).validate());
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> new BulkRoleResponse(new RoleDTO[] {}, new BulkError[] {}, 
null).validate());
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/authorization/AccessControlDispatcher.java
 
b/core/src/main/java/org/apache/gravitino/authorization/AccessControlDispatcher.java
index f059744666..983054616b 100644
--- 
a/core/src/main/java/org/apache/gravitino/authorization/AccessControlDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/AccessControlDispatcher.java
@@ -25,6 +25,7 @@ import java.util.Set;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.bulk.BulkItemResult;
 import org.apache.gravitino.bulk.GroupAdd;
+import org.apache.gravitino.bulk.RoleAdd;
 import org.apache.gravitino.bulk.UserAdd;
 import org.apache.gravitino.exceptions.GroupAlreadyExistsException;
 import org.apache.gravitino.exceptions.IllegalRoleException;
@@ -333,6 +334,18 @@ public interface AccessControlDispatcher {
       List<SecurableObject> securableObjects)
       throws RoleAlreadyExistsException, NoSuchMetalakeException;
 
+  /**
+   * Creates roles in bulk.
+   *
+   * @param metalake The Metalake of the Roles.
+   * @param roles The Roles to create.
+   * @return The item-level bulk results.
+   * @throws NoSuchMetalakeException If the Metalake with the given name does 
not exist.
+   * @throws RuntimeException If creating the Roles encounters storage issues.
+   */
+  List<BulkItemResult<Role>> createRoles(String metalake, List<RoleAdd> roles)
+      throws NoSuchMetalakeException;
+
   /**
    * Gets a Role.
    *
@@ -357,6 +370,18 @@ public interface AccessControlDispatcher {
    */
   boolean deleteRole(String metalake, String role) throws 
NoSuchMetalakeException;
 
+  /**
+   * Deletes Roles in bulk.
+   *
+   * @param metalake The Metalake of the Roles.
+   * @param roles The names of the Roles.
+   * @return The item-level bulk results.
+   * @throws NoSuchMetalakeException If the Metalake with the given name does 
not exist.
+   * @throws RuntimeException If deleting the Roles encounters storage issues.
+   */
+  List<BulkItemResult<String>> deleteRoles(String metalake, List<String> roles)
+      throws NoSuchMetalakeException;
+
   Role overridePrivilegesInRole(
       String metalake, String role, List<SecurableObject> 
securableObjectsToOverride)
       throws NoSuchRoleException, NoSuchMetalakeException;
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 b0d39dc93b..f39ba02a73 100644
--- 
a/core/src/main/java/org/apache/gravitino/authorization/AccessControlManager.java
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/AccessControlManager.java
@@ -30,6 +30,7 @@ import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.bulk.BulkItemResult;
 import org.apache.gravitino.bulk.GroupAdd;
+import org.apache.gravitino.bulk.RoleAdd;
 import org.apache.gravitino.bulk.UserAdd;
 import org.apache.gravitino.exceptions.GroupAlreadyExistsException;
 import org.apache.gravitino.exceptions.IllegalRoleException;
@@ -334,6 +335,29 @@ public class AccessControlManager implements 
AccessControlDispatcher {
         () -> roleManager.createRole(metalake, role, properties, 
securableObjects));
   }
 
+  @Override
+  public List<BulkItemResult<Role>> createRoles(String metalake, List<RoleAdd> 
roles)
+      throws NoSuchMetalakeException {
+    return TreeLockUtils.doWithTreeLock(
+        
NameIdentifier.of(AuthorizationUtils.ofRoleNamespace(metalake).levels()),
+        LockType.WRITE,
+        () -> {
+          List<BulkItemResult<Role>> results = 
Lists.newArrayListWithCapacity(roles.size());
+          for (int index = 0; index < roles.size(); index++) {
+            RoleAdd role = roles.get(index);
+            try {
+              Role createdRole =
+                  roleManager.createRole(
+                      metalake, role.name(), role.properties(), 
role.securableObjects());
+              results.add(BulkItemResult.success(index, role.name(), 
createdRole));
+            } catch (Exception e) {
+              results.add(BulkItemResult.failure(index, role.name(), e));
+            }
+          }
+          return results;
+        });
+  }
+
   @Override
   public Role getRole(String metalake, String role)
       throws NoSuchRoleException, NoSuchMetalakeException {
@@ -351,6 +375,33 @@ public class AccessControlManager implements 
AccessControlDispatcher {
         () -> roleManager.deleteRole(metalake, role));
   }
 
+  @Override
+  public List<BulkItemResult<String>> deleteRoles(String metalake, 
List<String> roles)
+      throws NoSuchMetalakeException {
+    return TreeLockUtils.doWithTreeLock(
+        
NameIdentifier.of(AuthorizationUtils.ofRoleNamespace(metalake).levels()),
+        LockType.WRITE,
+        () -> {
+          List<BulkItemResult<String>> results = 
Lists.newArrayListWithCapacity(roles.size());
+          for (int index = 0; index < roles.size(); index++) {
+            String role = roles.get(index);
+            try {
+              boolean deleted = roleManager.deleteRole(metalake, role);
+              if (!deleted) {
+                results.add(
+                    BulkItemResult.failure(
+                        index, role, new NoSuchRoleException("Role does not 
exist: %s", role)));
+                continue;
+              }
+              results.add(BulkItemResult.success(index, role));
+            } catch (Exception e) {
+              results.add(BulkItemResult.failure(index, role, e));
+            }
+          }
+          return results;
+        });
+  }
+
   @Override
   public String[] listRoleNames(String metalake) throws 
NoSuchMetalakeException {
     return TreeLockUtils.doWithTreeLock(
diff --git a/core/src/main/java/org/apache/gravitino/bulk/RoleAdd.java 
b/core/src/main/java/org/apache/gravitino/bulk/RoleAdd.java
new file mode 100644
index 0000000000..1165eb2aae
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/bulk/RoleAdd.java
@@ -0,0 +1,76 @@
+/*
+ * 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.List;
+import java.util.Map;
+import javax.annotation.Nullable;
+import org.apache.gravitino.authorization.SecurableObject;
+
+/** Represents one role to add in a bulk operation. */
+public final class RoleAdd {
+
+  private final String name;
+  @Nullable private final Map<String, String> properties;
+  private final List<SecurableObject> securableObjects;
+
+  /**
+   * Creates a role add item.
+   *
+   * @param name The role name.
+   * @param properties The role properties, or null if unset.
+   * @param securableObjects The securable objects of the role.
+   */
+  public RoleAdd(
+      String name,
+      @Nullable Map<String, String> properties,
+      List<SecurableObject> securableObjects) {
+    this.name = name;
+    this.properties = properties;
+    this.securableObjects = securableObjects;
+  }
+
+  /**
+   * Returns the role name.
+   *
+   * @return The role name.
+   */
+  public String name() {
+    return name;
+  }
+
+  /**
+   * Returns the role properties.
+   *
+   * @return The role properties, or null if unset.
+   */
+  @Nullable
+  public Map<String, String> properties() {
+    return properties;
+  }
+
+  /**
+   * Returns the securable objects.
+   *
+   * @return The securable objects of the role.
+   */
+  public List<SecurableObject> securableObjects() {
+    return securableObjects;
+  }
+}
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 b5afbcd6c7..b02dbfd02f 100644
--- 
a/core/src/main/java/org/apache/gravitino/hook/AccessControlHookDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/hook/AccessControlHookDispatcher.java
@@ -18,6 +18,7 @@
  */
 package org.apache.gravitino.hook;
 
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -38,6 +39,7 @@ import org.apache.gravitino.authorization.SecurableObject;
 import org.apache.gravitino.authorization.User;
 import org.apache.gravitino.bulk.BulkItemResult;
 import org.apache.gravitino.bulk.GroupAdd;
+import org.apache.gravitino.bulk.RoleAdd;
 import org.apache.gravitino.bulk.UserAdd;
 import org.apache.gravitino.exceptions.GroupAlreadyExistsException;
 import org.apache.gravitino.exceptions.IllegalRoleException;
@@ -228,6 +230,27 @@ public class AccessControlHookDispatcher implements 
AccessControlDispatcher {
     return createdRole;
   }
 
+  @Override
+  public List<BulkItemResult<Role>> createRoles(String metalake, List<RoleAdd> 
roles)
+      throws NoSuchMetalakeException {
+    List<BulkItemResult<Role>> results = dispatcher.createRoles(metalake, 
roles);
+    OwnerDispatcher ownerDispatcher = 
GravitinoEnv.getInstance().ownerDispatcher();
+    if (ownerDispatcher != null) {
+      results.stream()
+          .filter(BulkItemResult::succeeded)
+          .forEach(
+              result ->
+                  ownerDispatcher.setOwner(
+                      metalake,
+                      NameIdentifierUtil.toMetadataObject(
+                          AuthorizationUtils.ofRole(metalake, result.name()),
+                          Entity.EntityType.ROLE),
+                      PrincipalUtils.getCurrentUserName(),
+                      Owner.Type.USER));
+    }
+    return results;
+  }
+
   @Override
   public Role getRole(String metalake, String role)
       throws NoSuchRoleException, NoSuchMetalakeException {
@@ -249,6 +272,31 @@ public class AccessControlHookDispatcher implements 
AccessControlDispatcher {
     return resultOfDeleteRole;
   }
 
+  @Override
+  public List<BulkItemResult<String>> deleteRoles(String metalake, 
List<String> roles)
+      throws NoSuchMetalakeException {
+    Map<String, Long> roleIds = new HashMap<>();
+    for (String role : roles) {
+      try {
+        roleIds.put(role, ((RoleEntity) getRole(metalake, role)).id());
+      } catch (NoSuchRoleException e) {
+        LOG.debug(e.getMessage());
+      }
+    }
+
+    List<BulkItemResult<String>> results = dispatcher.deleteRoles(metalake, 
roles);
+    results.stream()
+        .filter(BulkItemResult::succeeded)
+        .forEach(
+            result -> {
+              Long roleId = roleIds.get(result.name());
+              if (roleId != null) {
+                notifyRoleUserRelChange(roleId);
+              }
+            });
+    return results;
+  }
+
   @Override
   public String[] listRoleNames(String metalake) throws 
NoSuchMetalakeException {
     return dispatcher.listRoleNames(metalake);
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 16a8b83378..efd7c09b8c 100644
--- 
a/core/src/main/java/org/apache/gravitino/listener/AccessControlEventDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/listener/AccessControlEventDispatcher.java
@@ -34,6 +34,7 @@ import org.apache.gravitino.authorization.SecurableObject;
 import org.apache.gravitino.authorization.User;
 import org.apache.gravitino.bulk.BulkItemResult;
 import org.apache.gravitino.bulk.GroupAdd;
+import org.apache.gravitino.bulk.RoleAdd;
 import org.apache.gravitino.bulk.UserAdd;
 import org.apache.gravitino.exceptions.GroupAlreadyExistsException;
 import org.apache.gravitino.exceptions.IllegalRoleException;
@@ -589,6 +590,34 @@ public class AccessControlEventDispatcher implements 
AccessControlDispatcher {
     }
   }
 
+  /** {@inheritDoc} */
+  @Override
+  public List<BulkItemResult<Role>> createRoles(String metalake, List<RoleAdd> 
roles)
+      throws NoSuchMetalakeException {
+    String initiator = PrincipalUtils.getCurrentUserName();
+    roles.forEach(
+        role ->
+            eventBus.dispatchEvent(
+                new CreateRolePreEvent(
+                    initiator, metalake, role.name(), role.properties(), 
role.securableObjects())));
+
+    try {
+      List<BulkItemResult<Role>> results = dispatcher.createRoles(metalake, 
roles);
+      results.forEach(result -> dispatchCreateRoleResultEvent(initiator, 
metalake, roles, result));
+      return results;
+    } catch (Exception e) {
+      roles.forEach(
+          role ->
+              eventBus.dispatchEvent(
+                  new CreateRoleFailureEvent(
+                      initiator,
+                      metalake,
+                      e,
+                      new RoleInfo(role.name(), role.properties(), 
role.securableObjects()))));
+      throw e;
+    }
+  }
+
   /** {@inheritDoc} */
   @Override
   public Role getRole(String metalake, String role)
@@ -624,6 +653,25 @@ public class AccessControlEventDispatcher implements 
AccessControlDispatcher {
     }
   }
 
+  /** {@inheritDoc} */
+  @Override
+  public List<BulkItemResult<String>> deleteRoles(String metalake, 
List<String> roles)
+      throws NoSuchMetalakeException {
+    String initiator = PrincipalUtils.getCurrentUserName();
+    roles.forEach(
+        role -> eventBus.dispatchEvent(new DeleteRolePreEvent(initiator, 
metalake, role)));
+
+    try {
+      List<BulkItemResult<String>> results = dispatcher.deleteRoles(metalake, 
roles);
+      results.forEach(result -> dispatchDeleteRoleResultEvent(initiator, 
metalake, result));
+      return results;
+    } catch (Exception e) {
+      roles.forEach(
+          role -> eventBus.dispatchEvent(new DeleteRoleFailureEvent(initiator, 
metalake, e, role)));
+      throw e;
+    }
+  }
+
   /** {@inheritDoc} */
   @Override
   public String[] listRoleNames(String metalake) throws 
NoSuchMetalakeException {
@@ -773,4 +821,32 @@ public class AccessControlEventDispatcher implements 
AccessControlDispatcher {
           new RemoveGroupFailureEvent(initiator, metalake, 
result.error().get(), result.name()));
     }
   }
+
+  private void dispatchCreateRoleResultEvent(
+      String initiator, String metalake, List<RoleAdd> roles, 
BulkItemResult<Role> result) {
+    if (result.succeeded()) {
+      eventBus.dispatchEvent(
+          new CreateRoleEvent(initiator, metalake, new 
RoleInfo(result.value().get())));
+    } else {
+      RoleAdd role = roles.get(result.index());
+      eventBus.dispatchEvent(
+          new CreateRoleFailureEvent(
+              initiator,
+              metalake,
+              result.error().get(),
+              new RoleInfo(role.name(), role.properties(), 
role.securableObjects())));
+    }
+  }
+
+  private void dispatchDeleteRoleResultEvent(
+      String initiator, String metalake, BulkItemResult<String> result) {
+    if (result.succeeded()) {
+      eventBus.dispatchEvent(new DeleteRoleEvent(initiator, metalake, 
result.name(), true));
+    } else if (result.error().get() instanceof NoSuchRoleException) {
+      eventBus.dispatchEvent(new DeleteRoleEvent(initiator, metalake, 
result.name(), false));
+    } else {
+      eventBus.dispatchEvent(
+          new DeleteRoleFailureEvent(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 0b243c0942..33638b3057 100644
--- 
a/core/src/test/java/org/apache/gravitino/authorization/TestAccessControlManager.java
+++ 
b/core/src/test/java/org/apache/gravitino/authorization/TestAccessControlManager.java
@@ -66,6 +66,7 @@ import org.apache.gravitino.Namespace;
 import org.apache.gravitino.StringIdentifier;
 import org.apache.gravitino.bulk.BulkItemResult;
 import org.apache.gravitino.bulk.GroupAdd;
+import org.apache.gravitino.bulk.RoleAdd;
 import org.apache.gravitino.bulk.UserAdd;
 import org.apache.gravitino.catalog.CatalogManager;
 import org.apache.gravitino.catalog.CatalogTestUtils;
@@ -460,6 +461,30 @@ public class TestAccessControlManager {
                         "catalog", 
Lists.newArrayList(Privileges.UseCatalog.allow())))));
   }
 
+  @Test
+  public void testBulkCreateRoles() {
+    List<BulkItemResult<Role>> results =
+        accessControlManager.createRoles(
+            METALAKE,
+            Lists.newArrayList(
+                new RoleAdd(
+                    "bulk_role_1",
+                    ImmutableMap.of("key1", "value1"),
+                    Lists.newArrayList(
+                        SecurableObjects.ofCatalog(
+                            "catalog", 
Lists.newArrayList(Privileges.UseCatalog.allow())))),
+                new RoleAdd("bulk_role_2", null, Collections.emptyList()),
+                new RoleAdd("bulk_role_1", null, Collections.emptyList())));
+
+    Assertions.assertEquals(3, results.size());
+    Assertions.assertTrue(results.get(0).succeeded());
+    Assertions.assertEquals("bulk_role_1", 
results.get(0).value().get().name());
+    testProperties(ImmutableMap.of("key1", "value1"), 
results.get(0).value().get().properties());
+    Assertions.assertTrue(results.get(1).succeeded());
+    Assertions.assertFalse(results.get(2).succeeded());
+    Assertions.assertTrue(results.get(2).error().get() instanceof 
RoleAlreadyExistsException);
+  }
+
   @Test
   public void testLoadRole() {
     Map<String, String> props = ImmutableMap.of("k1", "v1");
@@ -508,6 +533,21 @@ public class TestAccessControlManager {
     Assertions.assertFalse(dropped1);
   }
 
+  @Test
+  public void testBulkDeleteRoles() {
+    accessControlManager.createRole(METALAKE, "bulk_delete_role", null, 
Collections.emptyList());
+
+    List<BulkItemResult<String>> results =
+        accessControlManager.deleteRoles(
+            METALAKE, Lists.newArrayList("bulk_delete_role", 
"missing_bulk_role"));
+
+    Assertions.assertEquals(2, results.size());
+    Assertions.assertTrue(results.get(0).succeeded());
+    Assertions.assertEquals("bulk_delete_role", results.get(0).name());
+    Assertions.assertFalse(results.get(1).succeeded());
+    Assertions.assertTrue(results.get(1).error().get() instanceof 
NoSuchRoleException);
+  }
+
   @Test
   public void testListRoles() {
     Map<String, String> props = ImmutableMap.of("k1", "v1");
diff --git 
a/core/src/test/java/org/apache/gravitino/listener/api/event/TestRoleEvent.java 
b/core/src/test/java/org/apache/gravitino/listener/api/event/TestRoleEvent.java
index 515fd3cdcb..11b12bf8cf 100644
--- 
a/core/src/test/java/org/apache/gravitino/listener/api/event/TestRoleEvent.java
+++ 
b/core/src/test/java/org/apache/gravitino/listener/api/event/TestRoleEvent.java
@@ -39,7 +39,9 @@ import org.apache.gravitino.authorization.Privileges;
 import org.apache.gravitino.authorization.Role;
 import org.apache.gravitino.authorization.SecurableObject;
 import org.apache.gravitino.authorization.User;
+import org.apache.gravitino.bulk.BulkItemResult;
 import org.apache.gravitino.exceptions.GravitinoRuntimeException;
+import org.apache.gravitino.exceptions.NoSuchRoleException;
 import org.apache.gravitino.listener.AccessControlEventDispatcher;
 import org.apache.gravitino.listener.DummyEventListener;
 import org.apache.gravitino.listener.EventBus;
@@ -254,6 +256,30 @@ public class TestRoleEvent {
     Assertions.assertEquals(otherRoleName, deleteRoleEvent.roleName());
   }
 
+  @Test
+  void testBulkDeleteRoleEventWithNotExistIdentifier() {
+    dummyEventListener.clear();
+
+    dispatcher.deleteRoles(METALAKE, ImmutableList.of(roleName, 
otherRoleName));
+
+    List<Event> events = dummyEventListener.getPostEvents();
+    Assertions.assertEquals(2, events.size());
+
+    Assertions.assertEquals(DeleteRoleEvent.class, events.get(0).getClass());
+    DeleteRoleEvent deleteRoleEvent = (DeleteRoleEvent) events.get(0);
+    Assertions.assertEquals(
+        NameIdentifierUtil.ofRole(METALAKE, roleName), 
deleteRoleEvent.identifier());
+    Assertions.assertEquals(roleName, deleteRoleEvent.roleName());
+    Assertions.assertTrue(deleteRoleEvent.isExists());
+
+    Assertions.assertEquals(DeleteRoleEvent.class, events.get(1).getClass());
+    DeleteRoleEvent deleteMissingRoleEvent = (DeleteRoleEvent) events.get(1);
+    Assertions.assertEquals(
+        NameIdentifierUtil.ofRole(METALAKE, otherRoleName), 
deleteMissingRoleEvent.identifier());
+    Assertions.assertEquals(otherRoleName, deleteMissingRoleEvent.roleName());
+    Assertions.assertFalse(deleteMissingRoleEvent.isExists());
+  }
+
   @Test
   void testDeleteRoleFailureEvent() {
     Assertions.assertThrowsExactly(
@@ -566,6 +592,14 @@ public class TestRoleEvent {
     when(dispatcher.createRole(METALAKE, roleName, properties, 
securableObjects)).thenReturn(role);
     when(dispatcher.deleteRole(METALAKE, roleName)).thenReturn(true);
     when(dispatcher.deleteRole(METALAKE, otherRoleName)).thenReturn(false);
+    when(dispatcher.deleteRoles(METALAKE, ImmutableList.of(roleName, 
otherRoleName)))
+        .thenReturn(
+            ImmutableList.of(
+                BulkItemResult.success(0, roleName),
+                BulkItemResult.failure(
+                    1,
+                    otherRoleName,
+                    new NoSuchRoleException("Role does not exist: %s", 
otherRoleName))));
     when(dispatcher.getRole(METALAKE, roleName)).thenReturn(role);
     when(dispatcher.grantRolesToGroup(
             METALAKE, ImmutableList.of(roleName, otherRoleName), groupName))
diff --git a/docs/open-api/bulk.yaml b/docs/open-api/bulk.yaml
index ee48563844..353203cdde 100644
--- a/docs/open-api/bulk.yaml
+++ b/docs/open-api/bulk.yaml
@@ -187,6 +187,90 @@ paths:
         "5xx":
           $ref: "./openapi.yaml#/components/responses/ServerErrorResponse"
 
+  /bulk/metalakes/{metalake}/roles/add:
+    parameters:
+      - $ref: "./openapi.yaml#/components/parameters/metalake"
+
+    post:
+      tags:
+        - access control
+      summary: Add roles in bulk
+      operationId: bulkAddRoles
+      description: Adds roles 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/BulkRoleAddRequest"
+            examples:
+              BulkRoleAddRequest:
+                $ref: "#/components/examples/BulkRoleAddRequest"
+      responses:
+        "200":
+          description: Returns successfully added roles and item-level errors
+          content:
+            application/vnd.gravitino.v1+json:
+              schema:
+                $ref: "#/components/schemas/BulkRoleResponse"
+              examples:
+                BulkRoleResponse:
+                  $ref: "#/components/examples/BulkRoleResponse"
+        "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}/roles/remove:
+    parameters:
+      - $ref: "./openapi.yaml#/components/parameters/metalake"
+
+    post:
+      tags:
+        - access control
+      summary: Remove roles in bulk
+      operationId: bulkRemoveRoles
+      description: Removes roles in best-effort mode. Failed items are 
returned in the top-level errors array. Role 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/BulkRemoveRolesRequest"
+      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/BulkRemoveRolesResponse"
+        "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:
@@ -214,6 +298,18 @@ components:
           items:
             $ref: "./groups.yaml#/components/schemas/GroupAddRequest"
 
+    BulkRoleAddRequest:
+      type: object
+      required:
+        - roles
+      properties:
+        roles:
+          type: array
+          minItems: 1
+          description: The maximum length is controlled by 
`gravitino.server.bulk.maxItems`, which defaults to 100.
+          items:
+            $ref: "./roles.yaml#/components/schemas/RoleCreateRequest"
+
     BulkRemoveRequest:
       type: object
       required:
@@ -313,6 +409,28 @@ components:
         summary:
           $ref: "#/components/schemas/BulkSummary"
 
+    BulkRoleResponse:
+      type: object
+      properties:
+        code:
+          type: integer
+          format: int32
+          description: Status code of the response
+          enum:
+            - 0
+        roles:
+          type: array
+          description: Successfully added roles
+          items:
+            $ref: "./roles.yaml#/components/schemas/Role"
+        errors:
+          type: array
+          description: Item-level errors
+          items:
+            $ref: "#/components/schemas/BulkError"
+        summary:
+          $ref: "#/components/schemas/BulkSummary"
+
     BulkRemoveResponse:
       type: object
       properties:
@@ -357,11 +475,24 @@ components:
         ]
       }
 
+    BulkRoleAddRequest:
+      value: {
+        "roles": [
+          {"name": "developer", "properties": {}, "securableObjects": []},
+          {"name": "operator", "properties": {}, "securableObjects": []}
+        ]
+      }
+
     BulkRemoveGroupsRequest:
       value: {
         "names": ["engineers", "analysts", "ghost"]
       }
 
+    BulkRemoveRolesRequest:
+      value: {
+        "names": ["developer", "operator", "ghost"]
+      }
+
     BulkUserResponse:
       value: {
         "code": 0,
@@ -428,6 +559,28 @@ components:
         "summary": {"total": 2, "succeeded": 1, "failed": 1}
       }
 
+    BulkRoleResponse:
+      value: {
+        "code": 0,
+        "roles": [
+          {
+            "name": "developer",
+            "properties": {},
+            "securableObjects": []
+          }
+        ],
+        "errors": [
+          {
+            "index": 1,
+            "name": "operator",
+            "code": 1004,
+            "type": "RoleAlreadyExistsException",
+            "message": "Role already exists: operator"
+          }
+        ],
+        "summary": {"total": 2, "succeeded": 1, "failed": 1}
+      }
+
     BulkRemoveGroupsResponse:
       value: {
         "code": 0,
@@ -443,3 +596,19 @@ components:
         ],
         "summary": {"total": 3, "succeeded": 2, "failed": 1}
       }
+
+    BulkRemoveRolesResponse:
+      value: {
+        "code": 0,
+        "names": ["developer", "operator"],
+        "errors": [
+          {
+            "index": 2,
+            "name": "ghost",
+            "code": 1003,
+            "type": "NoSuchRoleException",
+            "message": "Role 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 727187a8f9..70af40d2da 100644
--- a/docs/open-api/openapi.yaml
+++ b/docs/open-api/openapi.yaml
@@ -219,6 +219,12 @@ paths:
   /bulk/metalakes/{metalake}/groups/remove:
     $ref: 
"./bulk.yaml#/paths/~1bulk~1metalakes~1%7Bmetalake%7D~1groups~1remove"
 
+  /bulk/metalakes/{metalake}/roles/add:
+    $ref: "./bulk.yaml#/paths/~1bulk~1metalakes~1%7Bmetalake%7D~1roles~1add"
+
+  /bulk/metalakes/{metalake}/roles/remove:
+    $ref: "./bulk.yaml#/paths/~1bulk~1metalakes~1%7Bmetalake%7D~1roles~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 8bcbccb424..80bb901c36 100755
--- a/docs/security/access-control.md
+++ b/docs/security/access-control.md
@@ -283,16 +283,19 @@ owner-only; it does not accept a target schema.
 | Job template | `REGISTER_JOB_TEMPLATE` | `USE_JOB_TEMPLATE`                  
   | Owner           | Run a job: `RUN_JOB` and `USE_JOB_TEMPLATE` |
 | Job          |                         | Owner                               
   | Owner           |                                           |
 
-Bulk access-control APIs use the same privileges as the matching single-entity 
operations. These
-bulk operations are authorized once before processing the request. Bulk 
requests report item-level
-failures in `errors`.
-
-| API                                                 | Required privilege     
                    |
-|-----------------------------------------------------|--------------------------------------------|
-| `POST /api/bulk/metalakes/{metalake}/users/add`     | `OWNER` of the 
metalake or `MANAGE_USERS`  |
-| `POST /api/bulk/metalakes/{metalake}/users/remove`  | `OWNER` of the 
metalake or `MANAGE_USERS`  |
-| `POST /api/bulk/metalakes/{metalake}/groups/add`    | `OWNER` of the 
metalake or `MANAGE_GROUPS` |
-| `POST /api/bulk/metalakes/{metalake}/groups/remove` | `OWNER` of the 
metalake or `MANAGE_GROUPS` |
+Bulk access-control APIs use the same privileges as the matching single-entity 
operations. Most
+bulk operations are authorized once before processing the request. Role 
removal is authorized per
+item because each role can be removed by the metalake owner or by the owner of 
that role. Bulk
+requests report item-level failures in `errors`.
+
+| API                                                 | Required privilege     
                              |
+|-----------------------------------------------------|------------------------------------------------------|
+| `POST /api/bulk/metalakes/{metalake}/users/add`     | `OWNER` of the 
metalake or `MANAGE_USERS`            |
+| `POST /api/bulk/metalakes/{metalake}/users/remove`  | `OWNER` of the 
metalake or `MANAGE_USERS`            |
+| `POST /api/bulk/metalakes/{metalake}/groups/add`    | `OWNER` of the 
metalake or `MANAGE_GROUPS`           |
+| `POST /api/bulk/metalakes/{metalake}/groups/remove` | `OWNER` of the 
metalake or `MANAGE_GROUPS`           |
+| `POST /api/bulk/metalakes/{metalake}/roles/add`     | `OWNER` of the 
metalake or `CREATE_ROLE`             |
+| `POST /api/bulk/metalakes/{metalake}/roles/remove`  | `OWNER` of the 
metalake, or `OWNER` of the role      |
 
 For example, add users in bulk:
 
@@ -348,6 +351,33 @@ curl -X POST 
"http://localhost:8090/api/bulk/metalakes/{metalake}/groups/remove";
 }'
 ```
 
+For example, add roles in bulk:
+
+```shell
+curl -X POST "http://localhost:8090/api/bulk/metalakes/{metalake}/roles/add"; \
+  -H "Authorization: Bearer $MANAGER_TOKEN" \
+  -H "Accept: application/vnd.gravitino.v1+json" \
+  -H "Content-Type: application/json" \
+  -d '{
+  "roles": [
+    {"name": "analyst", "properties": {}, "securableObjects": []},
+    {"name": "developer", "properties": {}, "securableObjects": []}
+  ]
+}'
+```
+
+Remove roles in bulk:
+
+```shell
+curl -X POST 
"http://localhost:8090/api/bulk/metalakes/{metalake}/roles/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/rest/BulkOperations.java 
b/server/src/main/java/org/apache/gravitino/server/web/rest/BulkOperations.java
index c7db58227d..23e0bda28e 100644
--- 
a/server/src/main/java/org/apache/gravitino/server/web/rest/BulkOperations.java
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/rest/BulkOperations.java
@@ -20,9 +20,13 @@ package org.apache.gravitino.server.web.rest;
 
 import com.codahale.metrics.annotation.ResponseMetered;
 import com.codahale.metrics.annotation.Timed;
+import com.google.common.collect.Sets;
+import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Comparator;
 import java.util.List;
 import java.util.Optional;
+import java.util.Set;
 import java.util.stream.Collectors;
 import javax.servlet.http.HttpServletRequest;
 import javax.ws.rs.POST;
@@ -36,31 +40,50 @@ 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.AuthorizationUtils;
 import org.apache.gravitino.authorization.Group;
 import org.apache.gravitino.authorization.Owner;
 import org.apache.gravitino.authorization.OwnerDispatcher;
+import org.apache.gravitino.authorization.Privilege;
+import org.apache.gravitino.authorization.Role;
+import org.apache.gravitino.authorization.SecurableObject;
+import org.apache.gravitino.authorization.SecurableObjects;
 import org.apache.gravitino.authorization.User;
 import org.apache.gravitino.bulk.BulkItemResult;
 import org.apache.gravitino.bulk.BulkManager;
 import org.apache.gravitino.bulk.GroupAdd;
+import org.apache.gravitino.bulk.RoleAdd;
 import org.apache.gravitino.bulk.UserAdd;
 import org.apache.gravitino.dto.authorization.GroupDTO;
+import org.apache.gravitino.dto.authorization.PrivilegeDTO;
+import org.apache.gravitino.dto.authorization.RoleDTO;
+import org.apache.gravitino.dto.authorization.SecurableObjectDTO;
 import org.apache.gravitino.dto.authorization.UserDTO;
 import org.apache.gravitino.dto.requests.BulkGroupAddRequest;
 import org.apache.gravitino.dto.requests.BulkRemoveRequest;
+import org.apache.gravitino.dto.requests.BulkRoleAddRequest;
 import org.apache.gravitino.dto.requests.BulkUserAddRequest;
+import org.apache.gravitino.dto.requests.RoleCreateRequest;
 import org.apache.gravitino.dto.responses.BulkError;
 import org.apache.gravitino.dto.responses.BulkGroupResponse;
 import org.apache.gravitino.dto.responses.BulkRemoveResponse;
+import org.apache.gravitino.dto.responses.BulkRoleResponse;
 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.exceptions.ForbiddenException;
+import org.apache.gravitino.exceptions.IllegalMetadataObjectException;
+import org.apache.gravitino.exceptions.NoSuchMetadataObjectException;
 import org.apache.gravitino.metalake.MetalakeManager;
 import org.apache.gravitino.metrics.MetricNames;
+import org.apache.gravitino.server.authorization.MetadataAuthzHelper;
 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;
+import org.apache.gravitino.utils.MetadataObjectUtil;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+import org.apache.gravitino.utils.PrincipalUtils;
 
 /** Provides best-effort bulk APIs for metalake access-control entities. */
 @NameBindings.AccessControlInterfaces
@@ -69,7 +92,10 @@ public class BulkOperations {
 
   private static final String USERS_FIELD_NAME = "users";
   private static final String GROUPS_FIELD_NAME = "groups";
+  private static final String ROLES_FIELD_NAME = "roles";
   private static final String NAMES_FIELD_NAME = "names";
+  private static final String DELETE_ROLE_AUTHORIZATION_EXPRESSION =
+      "METALAKE::OWNER || ROLE::OWNER";
 
   private final BulkManager bulkManager;
   private final AccessControlDispatcher accessControlDispatcher;
@@ -311,4 +337,211 @@ public class BulkOperations {
       return ExceptionHandlers.handleGroupException(OperationType.REMOVE, "", 
metalake, e);
     }
   }
+
+  /**
+   * Adds roles in bulk.
+   *
+   * @param metalake The metalake name.
+   * @param request The bulk role add request.
+   * @return The bulk role response.
+   */
+  @POST
+  @Path("roles/add")
+  @Produces("application/vnd.gravitino.v1+json")
+  @Timed(name = "bulk-add-role." + MetricNames.HTTP_PROCESS_DURATION, absolute 
= true)
+  @ResponseMetered(name = "bulk-add-role", absolute = true)
+  @AuthorizationExpression(expression = "METALAKE::OWNER || 
METALAKE::CREATE_ROLE")
+  public Response addRoles(
+      @PathParam("metalake") @AuthorizationMetadata(type = 
Entity.EntityType.METALAKE)
+          String metalake,
+      BulkRoleAddRequest request) {
+    try {
+      return Utils.doAs(
+          httpRequest,
+          () -> {
+            request.validate();
+            bulkManager.checkBulkSize(ROLES_FIELD_NAME, 
request.getRoles().length);
+            MetalakeManager.checkMetalakeInUse(metalake);
+
+            List<RoleAdd> roles = new ArrayList<>();
+            List<Integer> originalIndexes = new ArrayList<>();
+            List<BulkItemResult<Role>> results = new ArrayList<>();
+            for (int index = 0; index < request.getRoles().length; index++) {
+              try {
+                roles.add(toRoleAdd(metalake, request.getRoles()[index]));
+                originalIndexes.add(index);
+              } catch (Exception e) {
+                results.add(BulkItemResult.failure(index, 
request.getRoles()[index].getName(), e));
+              }
+            }
+            results.addAll(
+                remapRoleResults(
+                    accessControlDispatcher.createRoles(metalake, roles), 
originalIndexes));
+            results.sort(Comparator.comparingInt(BulkItemResult::index));
+
+            RoleDTO[] rolesResponse =
+                results.stream()
+                    .filter(BulkItemResult::succeeded)
+                    .map(result -> DTOConverters.toDTO(result.value().get()))
+                    .toArray(RoleDTO[]::new);
+            BulkError[] errors =
+                results.stream()
+                    .filter(result -> !result.succeeded())
+                    .map(bulkManager::toBulkError)
+                    .toArray(BulkError[]::new);
+            return Utils.ok(
+                new BulkRoleResponse(
+                    rolesResponse,
+                    errors,
+                    new BulkSummary(results.size(), rolesResponse.length, 
errors.length)));
+          });
+    } catch (Exception e) {
+      return ExceptionHandlers.handleRoleException(OperationType.CREATE, "", 
metalake, e);
+    }
+  }
+
+  /**
+   * Removes roles in bulk.
+   *
+   * @param metalake The metalake name.
+   * @param request The bulk remove request.
+   * @return The bulk remove response.
+   */
+  @POST
+  @Path("roles/remove")
+  @Produces("application/vnd.gravitino.v1+json")
+  @Timed(name = "bulk-remove-role." + MetricNames.HTTP_PROCESS_DURATION, 
absolute = true)
+  @ResponseMetered(name = "bulk-remove-role", absolute = true)
+  @AuthorizationExpression(expression = "")
+  public Response removeRoles(
+      @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);
+
+            List<String> roles = new ArrayList<>();
+            List<Integer> originalIndexes = new ArrayList<>();
+            List<BulkItemResult<String>> results = new ArrayList<>();
+            for (int index = 0; index < request.getNames().length; index++) {
+              String role = request.getNames()[index];
+              try {
+                checkDeleteRoleAuthorization(metalake, role);
+                roles.add(role);
+                originalIndexes.add(index);
+              } catch (Exception e) {
+                results.add(BulkItemResult.failure(index, role, e));
+              }
+            }
+            results.addAll(
+                remapStringResults(
+                    accessControlDispatcher.deleteRoles(metalake, roles), 
originalIndexes));
+            results.sort(Comparator.comparingInt(BulkItemResult::index));
+
+            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.handleRoleException(OperationType.DELETE, "", 
metalake, e);
+    }
+  }
+
+  private RoleAdd toRoleAdd(String metalake, RoleCreateRequest request) {
+    Set<MetadataObject> metadataObjects = Sets.newHashSet();
+    for (SecurableObjectDTO object : request.getSecurableObjects()) {
+      MetadataObject metadataObject = 
MetadataObjects.parse(object.getFullName(), object.type());
+      if (metadataObjects.contains(metadataObject)) {
+        throw new IllegalArgumentException(
+            String.format(
+                "Doesn't support specifying duplicated securable objects %s 
type %s",
+                object.fullName(), object.type()));
+      } else {
+        metadataObjects.add(metadataObject);
+      }
+
+      Set<Privilege> privileges = Sets.newHashSet(object.privileges());
+      AuthorizationUtils.checkDuplicatedNamePrivilege(privileges);
+      try {
+        for (Privilege privilege : object.privileges()) {
+          AuthorizationUtils.checkPrivilege((PrivilegeDTO) privilege, object, 
metalake);
+        }
+        MetadataObjectUtil.checkMetadataObject(metalake, object);
+      } catch (NoSuchMetadataObjectException nsm) {
+        throw new IllegalMetadataObjectException(nsm);
+      }
+    }
+
+    List<SecurableObject> securableObjects =
+        Arrays.stream(request.getSecurableObjects())
+            .map(
+                securableObjectDTO ->
+                    SecurableObjects.parse(
+                        securableObjectDTO.fullName(),
+                        securableObjectDTO.type(),
+                        securableObjectDTO.privileges().stream()
+                            .map(
+                                privilege ->
+                                    
DTOConverters.fromPrivilegeDTO((PrivilegeDTO) privilege))
+                            .collect(Collectors.toList())))
+            .collect(Collectors.toList());
+    return new RoleAdd(request.getName(), request.getProperties(), 
securableObjects);
+  }
+
+  private void checkDeleteRoleAuthorization(String metalake, String role) {
+    boolean allowed =
+        MetadataAuthzHelper.checkAccess(
+            NameIdentifierUtil.ofRole(metalake, role),
+            Entity.EntityType.ROLE,
+            DELETE_ROLE_AUTHORIZATION_EXPRESSION);
+    if (!allowed) {
+      throw new ForbiddenException(
+          "User '%s' is not authorized to perform operation '%s' on metadata 
'%s' with expression '%s'",
+          PrincipalUtils.getCurrentUserName(),
+          "removeRoles",
+          NameIdentifierUtil.ofRole(metalake, role),
+          DELETE_ROLE_AUTHORIZATION_EXPRESSION);
+    }
+  }
+
+  private List<BulkItemResult<Role>> remapRoleResults(
+      List<BulkItemResult<Role>> results, List<Integer> originalIndexes) {
+    return results.stream()
+        .map(
+            result ->
+                result.succeeded()
+                    ? BulkItemResult.success(
+                        originalIndexes.get(result.index()), result.name(), 
result.value().get())
+                    : BulkItemResult.<Role>failure(
+                        originalIndexes.get(result.index()), result.name(), 
result.error().get()))
+        .collect(Collectors.toList());
+  }
+
+  private List<BulkItemResult<String>> remapStringResults(
+      List<BulkItemResult<String>> results, List<Integer> originalIndexes) {
+    return results.stream()
+        .map(
+            result ->
+                result.succeeded()
+                    ? BulkItemResult.<String>success(
+                        originalIndexes.get(result.index()), result.name())
+                    : BulkItemResult.<String>failure(
+                        originalIndexes.get(result.index()), result.name(), 
result.error().get()))
+        .collect(Collectors.toList());
+  }
 }
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestBulkOperations.java
 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestBulkOperations.java
index 35551bae92..30458b689d 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestBulkOperations.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestBulkOperations.java
@@ -46,31 +46,40 @@ import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.authorization.AccessControlManager;
 import org.apache.gravitino.authorization.Group;
 import org.apache.gravitino.authorization.OwnerDispatcher;
+import org.apache.gravitino.authorization.Role;
 import org.apache.gravitino.authorization.User;
 import org.apache.gravitino.bulk.BulkItemResult;
 import org.apache.gravitino.bulk.BulkManager;
 import org.apache.gravitino.bulk.GroupAdd;
+import org.apache.gravitino.bulk.RoleAdd;
 import org.apache.gravitino.bulk.UserAdd;
 import org.apache.gravitino.config.ConfigEntry;
 import org.apache.gravitino.connector.PropertiesMetadata;
+import org.apache.gravitino.dto.authorization.SecurableObjectDTO;
 import org.apache.gravitino.dto.requests.BulkGroupAddRequest;
 import org.apache.gravitino.dto.requests.BulkRemoveRequest;
+import org.apache.gravitino.dto.requests.BulkRoleAddRequest;
 import org.apache.gravitino.dto.requests.BulkUserAddRequest;
 import org.apache.gravitino.dto.requests.GroupAddRequest;
+import org.apache.gravitino.dto.requests.RoleCreateRequest;
 import org.apache.gravitino.dto.requests.UserAddRequest;
 import org.apache.gravitino.dto.responses.BulkGroupResponse;
 import org.apache.gravitino.dto.responses.BulkRemoveResponse;
+import org.apache.gravitino.dto.responses.BulkRoleResponse;
 import org.apache.gravitino.dto.responses.BulkUserResponse;
 import org.apache.gravitino.dto.responses.ErrorConstants;
 import org.apache.gravitino.dto.responses.ErrorResponse;
 import org.apache.gravitino.exceptions.GroupAlreadyExistsException;
 import org.apache.gravitino.exceptions.NoSuchGroupException;
+import org.apache.gravitino.exceptions.NoSuchRoleException;
 import org.apache.gravitino.exceptions.NoSuchUserException;
+import org.apache.gravitino.exceptions.RoleAlreadyExistsException;
 import org.apache.gravitino.exceptions.UserAlreadyExistsException;
 import org.apache.gravitino.lock.LockManager;
 import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.BaseMetalake;
 import org.apache.gravitino.meta.GroupEntity;
+import org.apache.gravitino.meta.RoleEntity;
 import org.apache.gravitino.meta.UserEntity;
 import org.apache.gravitino.rest.RESTUtils;
 import org.glassfish.hk2.utilities.binding.AbstractBinder;
@@ -304,6 +313,72 @@ public class TestBulkOperations extends BaseOperationsTest 
{
     Assertions.assertEquals(ErrorConstants.NOT_FOUND_CODE, 
bulkResponse.getErrors()[0].getCode());
   }
 
+  @Test
+  public void testBulkAddRolesBestEffort() {
+    Role role1 = buildRole("role1");
+    when(manager.createRoles(any(), any()))
+        .thenReturn(
+            Arrays.asList(
+                BulkItemResult.success(0, "role1", role1),
+                BulkItemResult.failure(
+                    1, "role2", new RoleAlreadyExistsException("Role already 
exists: role2"))));
+
+    BulkRoleAddRequest request =
+        new BulkRoleAddRequest(
+            new RoleCreateRequest[] {
+              new RoleCreateRequest(
+                  "role1", Collections.singletonMap("k1", "v1"), new 
SecurableObjectDTO[] {}),
+              new RoleCreateRequest("role2", Collections.emptyMap(), new 
SecurableObjectDTO[] {})
+            });
+    Response response =
+        target("/bulk/metalakes/metalake1/roles/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());
+    BulkRoleResponse bulkResponse = 
response.readEntity(BulkRoleResponse.class);
+    Assertions.assertEquals(1, bulkResponse.getRoles().length);
+    Assertions.assertEquals("role1", bulkResponse.getRoles()[0].name());
+    Assertions.assertEquals(1, bulkResponse.getErrors().length);
+    Assertions.assertEquals(1, bulkResponse.getErrors()[0].getIndex());
+    Assertions.assertEquals("role2", 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<RoleAdd>> rolesCaptor = 
ArgumentCaptor.forClass(List.class);
+    Mockito.verify(manager).createRoles(eq("metalake1"), 
rolesCaptor.capture());
+    Assertions.assertEquals("role1", rolesCaptor.getValue().get(0).name());
+    Assertions.assertEquals("v1", 
rolesCaptor.getValue().get(0).properties().get("k1"));
+  }
+
+  @Test
+  public void testBulkRemoveRolesBestEffort() {
+    when(manager.deleteRoles(any(), any()))
+        .thenReturn(
+            Arrays.asList(
+                BulkItemResult.success(0, "role1"),
+                BulkItemResult.failure(
+                    1, "ghost", new NoSuchRoleException("Role does not exist: 
ghost"))));
+
+    BulkRemoveRequest request = new BulkRemoveRequest(new String[] {"role1", 
"ghost"});
+    Response response =
+        target("/bulk/metalakes/metalake1/roles/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[] {"role1"}, 
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 testRemoveUsersWithNullRequest() {
     Response response =
@@ -360,6 +435,17 @@ public class TestBulkOperations extends BaseOperationsTest 
{
                     MediaType.APPLICATION_JSON_TYPE));
     Assertions.assertEquals(
         Response.Status.BAD_REQUEST.getStatusCode(), 
emptyGroupResponse.getStatus());
+
+    Response emptyRoleResponse =
+        target("/bulk/metalakes/metalake1/roles/add")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .post(
+                Entity.entity(
+                    new BulkRoleAddRequest(new RoleCreateRequest[] {}),
+                    MediaType.APPLICATION_JSON_TYPE));
+    Assertions.assertEquals(
+        Response.Status.BAD_REQUEST.getStatusCode(), 
emptyRoleResponse.getStatus());
   }
 
   private User buildUser(String user) {
@@ -381,4 +467,15 @@ public class TestBulkOperations extends BaseOperationsTest 
{
             
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build())
         .build();
   }
+
+  private Role buildRole(String role) {
+    return RoleEntity.builder()
+        .withId(1L)
+        .withName(role)
+        .withProperties(Collections.emptyMap())
+        .withSecurableObjects(Collections.emptyList())
+        .withAuditInfo(
+            
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build())
+        .build();
+  }
 }

Reply via email to