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