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();
+ }
}