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 ab0d52bae5 [#11835] feat: external-id user/group REST create APIs and 
AccessControlIT (#11921)
ab0d52bae5 is described below

commit ab0d52bae5b056c6b2e4816006dd58ee0bc2e333
Author: MaSai <[email protected]>
AuthorDate: Tue Jul 7 19:27:38 2026 +0800

    [#11835] feat: external-id user/group REST create APIs and AccessControlIT 
(#11921)
    
    ### What changes were proposed in this pull request?
    
    Extend 8090 user/group create REST APIs and Java client to support
    optional external-id create, and add integration coverage for the
    REST-exposed dispatcher paths.
    
    **REST (`server`)**
    - `POST /metalakes/{metalake}/users`: route to `addUser(metalake, name,
    externalId, enabled)` when `externalId` is present; otherwise keep
    existing `addUser(metalake, name)` path
    - `POST /metalakes/{metalake}/groups`: route to `addGroup(metalake,
    name, externalId)` when `externalId` is present; otherwise keep existing
    `addGroup(metalake, name)` path
    
    **DTOs (`common`)**
    - Extend `UserAddRequest` with optional `externalId` and optional
    `enabled` (defaults to `true` when unset)
    - Extend `GroupAddRequest` with optional `externalId`
    
    **Java client (`clients/client-java`)**
    - Add `GravitinoMetalake.addUser(name, externalId, enabled)` and
    `addGroup(name, externalId)`
    - Delegate the new overloads from `GravitinoClient`
    
    **Docs**
    - Update `docs/security/access-control.md` with external-id create
    examples for REST and Java client
    - Update OpenAPI schemas in `docs/open-api/users.yaml` and
    `docs/open-api/groups.yaml`
    
    **Tests**
    - Add REST unit tests in `TestUserOperations` and `TestGroupOperations`
    - Add client unit tests in `TestUserGroup`
    - Add `AccessControlIT.testManageUsersAndGroupsWithExternalId` for
    end-to-end create/read/duplicate-externalId flows
    
    Fix: #11835
    
    Part of epic #11830. Split from #11918.
    
    ### Why are the changes needed?
    
    SCIM and external identity providers need to create users and groups
    through the 8090 REST layer with stable external identifiers, without
    breaking existing name-only create clients. Integration tests ensure the
    client → REST → dispatcher create path works against a real Gravitino
    server.
    
    ### Does this PR introduce _any_ user-facing change?
    
    1. `POST /metalakes/{metalake}/users` accepts optional request fields:
       - `externalId` (string)
    - `enabled` (boolean, default `true` when `externalId` is provided and
    `enabled` is omitted)
    2. `POST /metalakes/{metalake}/groups` accepts optional request field:
       - `externalId` (string)
    3. Create responses include `externalId` (and `enabled` for users) when
    provided
    4. Java client adds `addUser(name, externalId, enabled)` and
    `addGroup(name, externalId)`
    
    Note: lookup/enable/disable/delete-by-external-id dispatcher APIs from
    #11832/#11834 are not exposed on 8090 REST yet and are therefore not
    covered by `AccessControlIT`.
    
    ### How was this patch tested?
    
    ```bash
    export 
JAVA_HOME=/Library/Java/JavaVirtualMachines/microsoft-17.jdk/Contents/Home
    ./gradlew spotlessApply
    ./gradlew :server:test \
      --tests "org.apache.gravitino.server.web.rest.TestUserOperations" \
      --tests "org.apache.gravitino.server.web.rest.TestGroupOperations" \
      -PskipITs
    ./gradlew :clients:client-java:test \
      --tests "org.apache.gravitino.client.TestUserGroup" \
      -PskipITs
    ./gradlew :clients:client-java:test \
      --tests 
"org.apache.gravitino.client.integration.test.authorization.AccessControlIT.testManageUsersAndGroupsWithExternalId"
    ./gradlew :docs:build
    ```
    
    ---------
    
    Co-authored-by: Cursor <[email protected]>
---
 .../apache/gravitino/client/GravitinoClient.java   | 32 ++++++++++++
 .../apache/gravitino/client/GravitinoMetalake.java | 58 ++++++++++++++++++++++
 .../org/apache/gravitino/client/TestUserGroup.java | 46 +++++++++++++++++
 .../test/authorization/AccessControlIT.java        | 45 +++++++++++++++++
 .../gravitino/dto/requests/GroupAddRequest.java    | 22 +++++++-
 .../gravitino/dto/requests/UserAddRequest.java     | 28 ++++++++++-
 docs/open-api/groups.yaml                          |  9 +++-
 docs/open-api/users.yaml                           | 18 ++++++-
 docs/security/access-control.md                    | 42 +++++++++++++---
 .../gravitino/server/web/rest/GroupOperations.java | 12 +++--
 .../gravitino/server/web/rest/UserOperations.java  | 15 ++++--
 .../server/web/rest/TestGroupOperations.java       | 32 ++++++++++++
 .../server/web/rest/TestUserOperations.java        | 34 +++++++++++++
 13 files changed, 373 insertions(+), 20 deletions(-)

diff --git 
a/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoClient.java
 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoClient.java
index c7e19ba73c..16b222ba8d 100644
--- 
a/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoClient.java
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoClient.java
@@ -177,6 +177,22 @@ public class GravitinoClient extends GravitinoClientBase
     return getMetalake().addUser(user);
   }
 
+  /**
+   * Adds a new User with an external identifier.
+   *
+   * @param user The name of the User.
+   * @param externalId The external identifier of the User.
+   * @param enabled Whether the User is enabled.
+   * @return The added User instance.
+   * @throws UserAlreadyExistsException If a User with the same name or 
external id already exists.
+   * @throws NoSuchMetalakeException If the Metalake with the given name does 
not exist.
+   * @throws RuntimeException If adding the User encounters storage issues.
+   */
+  public User addUser(String user, String externalId, boolean enabled)
+      throws UserAlreadyExistsException, NoSuchMetalakeException {
+    return getMetalake().addUser(user, externalId, enabled);
+  }
+
   /**
    * Removes a User.
    *
@@ -236,6 +252,22 @@ public class GravitinoClient extends GravitinoClientBase
     return getMetalake().addGroup(group);
   }
 
+  /**
+   * Adds a new Group with an external identifier.
+   *
+   * @param group The name of the Group.
+   * @param externalId The external identifier of the Group.
+   * @return The Added Group instance.
+   * @throws GroupAlreadyExistsException If a Group with the same name or 
external id already
+   *     exists.
+   * @throws NoSuchMetalakeException If the Metalake with the given name does 
not exist.
+   * @throws RuntimeException If adding the Group encounters storage issues.
+   */
+  public Group addGroup(String group, String externalId)
+      throws GroupAlreadyExistsException, NoSuchMetalakeException {
+    return getMetalake().addGroup(group, externalId);
+  }
+
   /**
    * Removes a Group.
    *
diff --git 
a/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoMetalake.java
 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoMetalake.java
index 2be2b88d92..f88a33a227 100644
--- 
a/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoMetalake.java
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoMetalake.java
@@ -784,6 +784,35 @@ public class GravitinoMetalake extends MetalakeDTO
     return resp.getUser();
   }
 
+  /**
+   * Adds a new User with an external identifier.
+   *
+   * @param user The name of the User.
+   * @param externalId The external identifier of the User.
+   * @param enabled Whether the User is enabled.
+   * @return The added User instance.
+   * @throws UserAlreadyExistsException If a User with the same name or 
external id already exists.
+   * @throws NoSuchMetalakeException If the Metalake with the given name does 
not exist.
+   * @throws RuntimeException If adding the User encounters storage issues.
+   */
+  public User addUser(String user, String externalId, boolean enabled)
+      throws UserAlreadyExistsException, NoSuchMetalakeException {
+    UserAddRequest req = new UserAddRequest(user, externalId, enabled);
+    req.validate();
+
+    UserResponse resp =
+        restClient.post(
+            String.format(
+                API_METALAKES_USERS_PATH, RESTUtils.encodeString(this.name()), 
BLANK_PLACEHOLDER),
+            req,
+            UserResponse.class,
+            Collections.emptyMap(),
+            ErrorHandlers.userErrorHandler());
+    resp.validate();
+
+    return resp.getUser();
+  }
+
   /**
    * Removes a User.
    *
@@ -900,6 +929,35 @@ public class GravitinoMetalake extends MetalakeDTO
     return resp.getGroup();
   }
 
+  /**
+   * Adds a new Group with an external identifier.
+   *
+   * @param group The name of the Group.
+   * @param externalId The external identifier of the Group.
+   * @return The Added Group instance.
+   * @throws GroupAlreadyExistsException If a Group with the same name or 
external id already
+   *     exists.
+   * @throws NoSuchMetalakeException If the Metalake with the given name does 
not exist.
+   * @throws RuntimeException If adding the Group encounters storage issues.
+   */
+  public Group addGroup(String group, String externalId)
+      throws GroupAlreadyExistsException, NoSuchMetalakeException {
+    GroupAddRequest req = new GroupAddRequest(group, externalId);
+    req.validate();
+
+    GroupResponse resp =
+        restClient.post(
+            String.format(
+                API_METALAKES_GROUPS_PATH, 
RESTUtils.encodeString(this.name()), BLANK_PLACEHOLDER),
+            req,
+            GroupResponse.class,
+            Collections.emptyMap(),
+            ErrorHandlers.groupErrorHandler());
+    resp.validate();
+
+    return resp.getGroup();
+  }
+
   /**
    * Removes a Group.
    *
diff --git 
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestUserGroup.java
 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestUserGroup.java
index ff98b2ca6c..924acb9cf0 100644
--- 
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestUserGroup.java
+++ 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestUserGroup.java
@@ -125,6 +125,30 @@ public class TestUserGroup extends TestBase {
         RuntimeException.class, () -> gravitinoClient.addUser(username), 
"internal error");
   }
 
+  @Test
+  public void testAddUserWithExternalId() throws Exception {
+    String username = "user";
+    String externalId = "ext-user-1";
+    String userPath = withSlash(String.format(API_METALAKES_USERS_PATH, 
metalakeName, ""));
+    UserAddRequest request = new UserAddRequest(username, externalId, false);
+
+    UserDTO mockUser =
+        UserDTO.builder()
+            .withName(username)
+            .withExternalId(externalId)
+            .withEnabled(false)
+            .withAudit(
+                
AuditDTO.builder().withCreator("creator").withCreateTime(Instant.now()).build())
+            .build();
+    UserResponse userResponse = new UserResponse(mockUser);
+    buildMockResource(Method.POST, userPath, request, userResponse, SC_OK);
+
+    User addedUser = gravitinoClient.addUser(username, externalId, false);
+    Assertions.assertNotNull(addedUser);
+    Assertions.assertEquals(externalId, addedUser.externalId());
+    Assertions.assertFalse(addedUser.enabled());
+  }
+
   @Test
   public void testGetUsers() throws Exception {
     String username = "user";
@@ -272,6 +296,28 @@ public class TestUserGroup extends TestBase {
         RuntimeException.class, () -> gravitinoClient.addGroup(groupName), 
"internal error");
   }
 
+  @Test
+  public void testAddGroupWithExternalId() throws Exception {
+    String groupName = "group";
+    String externalId = "ext-group-1";
+    String groupPath = withSlash(String.format(API_METALAKES_GROUPS_PATH, 
metalakeName, ""));
+    GroupAddRequest request = new GroupAddRequest(groupName, externalId);
+
+    GroupDTO mockGroup =
+        GroupDTO.builder()
+            .withName(groupName)
+            .withExternalId(externalId)
+            .withAudit(
+                
AuditDTO.builder().withCreator("creator").withCreateTime(Instant.now()).build())
+            .build();
+    GroupResponse groupResponse = new GroupResponse(mockGroup);
+    buildMockResource(Method.POST, groupPath, request, groupResponse, SC_OK);
+
+    Group addedGroup = gravitinoClient.addGroup(groupName, externalId);
+    Assertions.assertNotNull(addedGroup);
+    Assertions.assertEquals(externalId, addedGroup.externalId());
+  }
+
   @Test
   public void testGetGroups() throws Exception {
     String groupName = "group";
diff --git 
a/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/AccessControlIT.java
 
b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/AccessControlIT.java
index dfcb0f8516..e5321c59c0 100644
--- 
a/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/AccessControlIT.java
+++ 
b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/AccessControlIT.java
@@ -202,6 +202,51 @@ public class AccessControlIT extends BaseIT {
     metalake.deleteRole("role2");
   }
 
+  @Test
+  void testManageUsersAndGroupsWithExternalId() {
+    String userExtId = "ext-user-it-1";
+    String username = "ext_user_it";
+    User user = metalake.addUser(username, userExtId, false);
+    Assertions.assertEquals(userExtId, user.externalId());
+    Assertions.assertFalse(user.enabled());
+
+    user = metalake.getUser(username);
+    Assertions.assertEquals(userExtId, user.externalId());
+    Assertions.assertFalse(user.enabled());
+
+    User listedUser =
+        Arrays.stream(metalake.listUsers())
+            .filter(u -> username.equals(u.name()))
+            .findFirst()
+            .orElseThrow();
+    Assertions.assertEquals(userExtId, listedUser.externalId());
+    Assertions.assertFalse(listedUser.enabled());
+
+    Assertions.assertThrows(
+        UserAlreadyExistsException.class, () -> 
metalake.addUser("dup_ext_user", userExtId, true));
+
+    String groupExtId = "ext-group-it-1";
+    String groupName = "ext_group_it";
+    Group group = metalake.addGroup(groupName, groupExtId);
+    Assertions.assertEquals(groupExtId, group.externalId());
+
+    group = metalake.getGroup(groupName);
+    Assertions.assertEquals(groupExtId, group.externalId());
+
+    Group listedGroup =
+        Arrays.stream(metalake.listGroups())
+            .filter(g -> groupName.equals(g.name()))
+            .findFirst()
+            .orElseThrow();
+    Assertions.assertEquals(groupExtId, listedGroup.externalId());
+
+    Assertions.assertThrows(
+        GroupAlreadyExistsException.class, () -> 
metalake.addGroup("dup_ext_group", groupExtId));
+
+    Assertions.assertTrue(metalake.removeUser(username));
+    Assertions.assertTrue(metalake.removeGroup(groupName));
+  }
+
   @Test
   @SuppressWarnings("deprecation")
   void testManageRoles() {
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/requests/GroupAddRequest.java 
b/common/src/main/java/org/apache/gravitino/dto/requests/GroupAddRequest.java
index e389332888..892cacb6e3 100644
--- 
a/common/src/main/java/org/apache/gravitino/dto/requests/GroupAddRequest.java
+++ 
b/common/src/main/java/org/apache/gravitino/dto/requests/GroupAddRequest.java
@@ -20,6 +20,7 @@ package org.apache.gravitino.dto.requests;
 
 import com.fasterxml.jackson.annotation.JsonProperty;
 import com.google.common.base.Preconditions;
+import javax.annotation.Nullable;
 import lombok.Builder;
 import lombok.EqualsAndHashCode;
 import lombok.Getter;
@@ -39,9 +40,13 @@ public class GroupAddRequest implements RESTRequest {
   @JsonProperty("name")
   private final String name;
 
+  @Nullable
+  @JsonProperty("externalId")
+  private final String externalId;
+
   /** Default constructor for GroupAddRequest. (Used for Jackson 
deserialization.) */
   public GroupAddRequest() {
-    this(null);
+    this(null, null);
   }
 
   /**
@@ -50,8 +55,19 @@ public class GroupAddRequest implements RESTRequest {
    * @param name The name of the group.
    */
   public GroupAddRequest(String name) {
+    this(name, null);
+  }
+
+  /**
+   * Creates a new GroupAddRequest.
+   *
+   * @param name The name of the group.
+   * @param externalId The external identifier of the group.
+   */
+  public GroupAddRequest(String name, String externalId) {
     super();
     this.name = name;
+    this.externalId = externalId;
   }
 
   /**
@@ -63,5 +79,9 @@ public class GroupAddRequest implements RESTRequest {
   public void validate() throws IllegalArgumentException {
     Preconditions.checkArgument(
         StringUtils.isNotBlank(name), "\"name\" field is required and cannot 
be empty");
+    if (externalId != null) {
+      Preconditions.checkArgument(
+          StringUtils.isNotBlank(externalId), "\"externalId\" field cannot be 
blank when provided");
+    }
   }
 }
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/requests/UserAddRequest.java 
b/common/src/main/java/org/apache/gravitino/dto/requests/UserAddRequest.java
index 6d5fb03b18..688f2a00cb 100644
--- a/common/src/main/java/org/apache/gravitino/dto/requests/UserAddRequest.java
+++ b/common/src/main/java/org/apache/gravitino/dto/requests/UserAddRequest.java
@@ -20,6 +20,7 @@ package org.apache.gravitino.dto.requests;
 
 import com.fasterxml.jackson.annotation.JsonProperty;
 import com.google.common.base.Preconditions;
+import javax.annotation.Nullable;
 import lombok.Builder;
 import lombok.EqualsAndHashCode;
 import lombok.Getter;
@@ -39,9 +40,17 @@ public class UserAddRequest implements RESTRequest {
   @JsonProperty("name")
   private final String name;
 
+  @Nullable
+  @JsonProperty("externalId")
+  private final String externalId;
+
+  @Nullable
+  @JsonProperty("enabled")
+  private final Boolean enabled;
+
   /** Default constructor for UserAddRequest. (Used for Jackson 
deserialization.) */
   public UserAddRequest() {
-    this(null);
+    this(null, null, null);
   }
 
   /**
@@ -50,8 +59,21 @@ public class UserAddRequest implements RESTRequest {
    * @param name The name of the user.
    */
   public UserAddRequest(String name) {
+    this(name, null, null);
+  }
+
+  /**
+   * Creates a new UserAddRequest.
+   *
+   * @param name The name of the user.
+   * @param externalId The external identifier of the user.
+   * @param enabled Whether the user is enabled.
+   */
+  public UserAddRequest(String name, String externalId, Boolean enabled) {
     super();
     this.name = name;
+    this.externalId = externalId;
+    this.enabled = enabled;
   }
 
   /**
@@ -63,5 +85,9 @@ public class UserAddRequest implements RESTRequest {
   public void validate() throws IllegalArgumentException {
     Preconditions.checkArgument(
         StringUtils.isNotBlank(name), "\"name\" field is required and cannot 
be empty");
+    if (externalId != null) {
+      Preconditions.checkArgument(
+          StringUtils.isNotBlank(externalId), "\"externalId\" field cannot be 
blank when provided");
+    }
   }
 }
diff --git a/docs/open-api/groups.yaml b/docs/open-api/groups.yaml
index e2e8108daf..464bd6b3ad 100644
--- a/docs/open-api/groups.yaml
+++ b/docs/open-api/groups.yaml
@@ -163,6 +163,9 @@ components:
         name:
           type: string
           description: The name of the group
+        externalId:
+          type: string
+          description: The external identifier of the group
         roles:
           type: array
           items:
@@ -179,6 +182,9 @@ components:
         name:
           type: string
           description: The name of the group
+        externalId:
+          type: string
+          description: The external identifier of the group
 
   responses:
     GroupResponse:
@@ -211,7 +217,8 @@ components:
   examples:
     GroupAddRequest:
       value: {
-        "name": "group1",
+        "name": "group",
+        "externalId": "ext-group"
       }
 
     NameListResponse:
diff --git a/docs/open-api/users.yaml b/docs/open-api/users.yaml
index 40e3a25cb0..39974c0fd6 100644
--- a/docs/open-api/users.yaml
+++ b/docs/open-api/users.yaml
@@ -163,6 +163,13 @@ components:
         name:
           type: string
           description: The name of the user
+        externalId:
+          type: string
+          description: The external identifier of the user
+        enabled:
+          type: boolean
+          description: Whether the user is enabled
+          default: true
         roles:
           type: array
           items:
@@ -179,6 +186,13 @@ components:
         name:
           type: string
           description: The name of the user
+        externalId:
+          type: string
+          description: The external identifier of the user
+        enabled:
+          type: boolean
+          description: Whether the user is enabled
+          default: true
 
   responses:
     UserResponse:
@@ -211,7 +225,9 @@ components:
   examples:
     UserAddRequest:
       value: {
-        "name": "user1",
+        "name": "user",
+        "externalId": "ext-user",
+        "enabled": true
       }
 
     NameListResponse:
diff --git a/docs/security/access-control.md b/docs/security/access-control.md
index e3bf34ef9c..5671932ab9 100644
--- a/docs/security/access-control.md
+++ b/docs/security/access-control.md
@@ -160,6 +160,8 @@ A user represents an individual identity in Gravitino. 
Users can be:
 - Granted one or more roles
 - Given different operating privileges based on their assigned roles
 - Made owners of securable objects
+- Correlated with an external identity provider through an optional 
`externalId`
+- Enabled or disabled through the optional `enabled` flag (`true` by default 
when creating with `externalId`)
 
 ### Group
 
@@ -167,6 +169,7 @@ A group is a collection of users that simplifies permission 
management by allowi
 - Grant permissions to multiple users at once
 - Manage access control for teams or departments
 - Assign roles that all group members will inherit
+- Correlated with an external identity provider through an optional 
`externalId`
 
 All users in a group inherit the roles and privileges granted to that group.
 
@@ -574,7 +577,9 @@ The following sections demonstrate how to perform common 
access control operatio
 
 ### Add a User
 
-Add a user to your metalake before using authorization features.
+Add a user to your metalake before using authorization features. The request 
body requires `name` and
+optionally accepts `externalId` and `enabled`. When `externalId` is omitted, 
Gravitino creates the user
+with the legacy name-only path. When `externalId` is provided, `enabled` 
defaults to `true` if omitted.
 
 <Tabs groupId='language' queryString>
 <TabItem value="shell" label="Shell">
@@ -582,7 +587,14 @@ Add a user to your metalake before using authorization 
features.
 ```shell
 curl -X POST -H "Accept: application/vnd.gravitino.v1+json" \
 -H "Content-Type: application/json" -d '{
-  "name": "user1"
+  "name": "user"
+}' http://localhost:8090/api/metalakes/test/users
+
+curl -X POST -H "Accept: application/vnd.gravitino.v1+json" \
+-H "Content-Type: application/json" -d '{
+  "name": "user",
+  "externalId": "ext-user",
+  "enabled": true
 }' http://localhost:8090/api/metalakes/test/users
 ```
 
@@ -592,7 +604,10 @@ curl -X POST -H "Accept: 
application/vnd.gravitino.v1+json" \
 ```java
 GravitinoClient client = ...
 User user =
-    client.addUser("user1");
+    client.addUser("user");
+
+User externalUser =
+    client.addUser("user", "ext-user", true);
 ```
 
 </TabItem>
@@ -628,7 +643,7 @@ User[] users = client.listUsers();
 
 ### Get a User
 
-Get a user by its name.
+Get a user by its name. The response includes `externalId` and `enabled` when 
they are set on the user.
 
 <Tabs groupId='language' queryString>
 <TabItem value="shell" label="Shell">
@@ -678,7 +693,9 @@ boolean removed =
 
 ### Add a Group
 
-Add the group to your metalake before you use the authorization.
+Add the group to your metalake before you use the authorization. The request 
body requires `name` and
+optionally accepts `externalId`. When `externalId` is omitted, Gravitino 
creates the group with the
+legacy name-only path.
 
 <Tabs groupId='language' queryString>
 <TabItem value="shell" label="Shell">
@@ -686,7 +703,13 @@ Add the group to your metalake before you use the 
authorization.
 ```shell
 curl -X POST -H "Accept: application/vnd.gravitino.v1+json" \
 -H "Content-Type: application/json" -d '{
-  "name": "group1"
+  "name": "group"
+}' http://localhost:8090/api/metalakes/test/groups
+
+curl -X POST -H "Accept: application/vnd.gravitino.v1+json" \
+-H "Content-Type: application/json" -d '{
+  "name": "group",
+  "externalId": "ext-group"
 }' http://localhost:8090/api/metalakes/test/groups
 ```
 
@@ -696,7 +719,10 @@ curl -X POST -H "Accept: 
application/vnd.gravitino.v1+json" \
 ```java
 GravitinoClient client = ...
 Group group =
-    client.addGroup("group1");
+    client.addGroup("group");
+
+Group externalGroup =
+    client.addGroup("group", "ext-group");
 ```
 
 </TabItem>
@@ -733,7 +759,7 @@ User[] users = client.listGroups();
 
 ### Get a Group
 
-Get a group by its name.
+Get a group by its name. The response includes `externalId` when it is set on 
the group.
 
 <Tabs groupId='language' queryString>
 <TabItem value="shell" label="Shell">
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/rest/GroupOperations.java
 
b/server/src/main/java/org/apache/gravitino/server/web/rest/GroupOperations.java
index eea20c3ccb..5cc083c7f7 100644
--- 
a/server/src/main/java/org/apache/gravitino/server/web/rest/GroupOperations.java
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/rest/GroupOperations.java
@@ -31,12 +31,14 @@ import javax.ws.rs.Produces;
 import javax.ws.rs.QueryParam;
 import javax.ws.rs.core.Context;
 import javax.ws.rs.core.Response;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.MetadataObjects;
 import org.apache.gravitino.Namespace;
 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.dto.requests.GroupAddRequest;
@@ -109,10 +111,12 @@ public class GroupOperations {
           () -> {
             request.validate();
             MetalakeManager.checkMetalakeInUse(metalake);
-            return Utils.ok(
-                new GroupResponse(
-                    DTOConverters.toDTO(
-                        accessControlManager.addGroup(metalake, 
request.getName()))));
+            Group addedGroup =
+                StringUtils.isNotBlank(request.getExternalId())
+                    ? accessControlManager.addGroup(
+                        metalake, request.getName(), request.getExternalId())
+                    : accessControlManager.addGroup(metalake, 
request.getName());
+            return Utils.ok(new 
GroupResponse(DTOConverters.toDTO(addedGroup)));
           });
     } catch (Exception e) {
       return ExceptionHandlers.handleGroupException(
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/rest/UserOperations.java 
b/server/src/main/java/org/apache/gravitino/server/web/rest/UserOperations.java
index 5bce5e8fc9..57e21c844c 100644
--- 
a/server/src/main/java/org/apache/gravitino/server/web/rest/UserOperations.java
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/rest/UserOperations.java
@@ -20,6 +20,7 @@ package org.apache.gravitino.server.web.rest;
 
 import com.codahale.metrics.annotation.ResponseMetered;
 import com.codahale.metrics.annotation.Timed;
+import java.util.Optional;
 import javax.servlet.http.HttpServletRequest;
 import javax.ws.rs.DELETE;
 import javax.ws.rs.DefaultValue;
@@ -31,6 +32,7 @@ import javax.ws.rs.Produces;
 import javax.ws.rs.QueryParam;
 import javax.ws.rs.core.Context;
 import javax.ws.rs.core.Response;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.MetadataObject;
@@ -159,10 +161,15 @@ public class UserOperations {
           () -> {
             request.validate();
             MetalakeManager.checkMetalakeInUse(metalake);
-            return Utils.ok(
-                new UserResponse(
-                    DTOConverters.toDTO(
-                        accessControlManager.addUser(metalake, 
request.getName()))));
+            User addedUser =
+                StringUtils.isNotBlank(request.getExternalId())
+                    ? accessControlManager.addUser(
+                        metalake,
+                        request.getName(),
+                        request.getExternalId(),
+                        Optional.ofNullable(request.getEnabled()).orElse(true))
+                    : accessControlManager.addUser(metalake, 
request.getName());
+            return Utils.ok(new UserResponse(DTOConverters.toDTO(addedUser)));
           });
     } catch (Exception e) {
       return ExceptionHandlers.handleUserException(
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestGroupOperations.java
 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestGroupOperations.java
index 64e6095dc3..5fd1084b6c 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestGroupOperations.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestGroupOperations.java
@@ -210,6 +210,38 @@ public class TestGroupOperations extends 
BaseOperationsTest {
     Assertions.assertEquals(RuntimeException.class.getSimpleName(), 
errorResponse2.getType());
   }
 
+  @Test
+  public void testAddGroupWithExternalId() throws IOException {
+    GroupAddRequest req = new GroupAddRequest("group1", "ext-group-1");
+    Group group =
+        GroupEntity.builder()
+            .withId(1L)
+            .withName("group1")
+            .withExternalId("ext-group-1")
+            .withRoleNames(Collections.emptyList())
+            .withAuditInfo(
+                
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build())
+            .build();
+
+    when(manager.addGroup(any(), any(), any())).thenReturn(group);
+
+    BaseMetalake metalake = mock(BaseMetalake.class);
+    PropertiesMetadata propertiesMetadata = mock(PropertiesMetadata.class);
+    when(propertiesMetadata.getOrDefault(any(), any())).thenReturn(true);
+    when(metalake.propertiesMetadata()).thenReturn(propertiesMetadata);
+    when(entityStore.get(any(), any(), any())).thenReturn(metalake);
+
+    Response resp =
+        target("/metalakes/metalake1/groups")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .post(Entity.entity(req, MediaType.APPLICATION_JSON_TYPE));
+
+    Assertions.assertEquals(Status.OK.getStatusCode(), resp.getStatus());
+    GroupResponse groupResponse = resp.readEntity(GroupResponse.class);
+    Assertions.assertEquals("ext-group-1", 
groupResponse.getGroup().externalId());
+  }
+
   @Test
   public void testGetGroup() throws IOException {
     Group group = buildGroup("group1");
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestUserOperations.java
 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestUserOperations.java
index bb82a5dcdc..eac08e9175 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestUserOperations.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestUserOperations.java
@@ -210,6 +210,40 @@ public class TestUserOperations extends BaseOperationsTest 
{
     Assertions.assertEquals(RuntimeException.class.getSimpleName(), 
errorResponse2.getType());
   }
 
+  @Test
+  public void testAddUserWithExternalId() throws IOException {
+    UserAddRequest req = new UserAddRequest("user1", "ext-1", false);
+    User user =
+        UserEntity.builder()
+            .withId(1L)
+            .withName("user1")
+            .withExternalId("ext-1")
+            .withEnabled(false)
+            .withRoleNames(Collections.emptyList())
+            .withAuditInfo(
+                
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build())
+            .build();
+
+    when(manager.addUser(any(), any(), any(), 
any(Boolean.class))).thenReturn(user);
+
+    BaseMetalake metalake = mock(BaseMetalake.class);
+    PropertiesMetadata propertiesMetadata = mock(PropertiesMetadata.class);
+    when(propertiesMetadata.getOrDefault(any(), any())).thenReturn(true);
+    when(metalake.propertiesMetadata()).thenReturn(propertiesMetadata);
+    when(entityStore.get(any(), any(), any())).thenReturn(metalake);
+
+    Response resp =
+        target("/metalakes/metalake1/users")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .post(Entity.entity(req, MediaType.APPLICATION_JSON_TYPE));
+
+    Assertions.assertEquals(Response.Status.OK.getStatusCode(), 
resp.getStatus());
+    UserResponse userResponse = resp.readEntity(UserResponse.class);
+    Assertions.assertEquals("ext-1", userResponse.getUser().externalId());
+    Assertions.assertFalse(userResponse.getUser().enabled());
+  }
+
   @Test
   public void testGetUser() throws IOException {
 

Reply via email to