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 e51be54441 [#11833] feat(core): Add JDBC-backed paginated list/count 
APIs for users and groups (#11882)
e51be54441 is described below

commit e51be54441cb72b7556d810f463b042fa0909775
Author: MaSai <[email protected]>
AuthorDate: Fri Aug 7 16:02:33 2026 +0800

    [#11833] feat(core): Add JDBC-backed paginated list/count APIs for users 
and groups (#11882)
    
    ### What changes were proposed in this pull request?
    
    Adds JDBC-backed pagination for users and groups at metalake scope.
    
    - Add `PagedResult<T>` in `core` (internal) with `totalCount()` and
    `items()`
    - Add dispatcher APIs:
      - `PagedResult<User> listUsers(metalake, offset, limit)`
      - `long countUsers(metalake)`
      - `PagedResult<Group> listGroups(metalake, offset, limit)`
      - `long countGroups(metalake)`
    - Implement pagination via `UserMetaService` / `GroupMetaService` with
    `LIMIT`/`OFFSET` SQL ordered by `user_id` / `group_id ASC`, including
    role joins
    - Missing metalake throws `NoSuchMetalakeException` (consistent with
    existing list APIs)
    - Wire through `AccessControlManager`, `UserGroupManager`, hook
    dispatcher, and `AccessControlEventDispatcher` with listener events for
    paged list and count (pre/success/failure), following the same pattern
    as existing list APIs
    - Keep existing full-list APIs unchanged; no pagination on
    `listUserNames` / `listGroupNames`; no REST exposure
    
    Fix: #11833
    
    ### Why are the changes needed?
    
    Large metalakes need efficient paginated list and count at the
    dispatcher layer without loading entire user/group populations into
    memory.
    
    ### Does this PR introduce _any_ user-facing change?
    
    1. New internal dispatcher APIs for paginated list and count (not
    exposed via REST in this PR).
    2. New listener event types / `OperationType` values for paged list and
    count operations.
    
    ### How was this patch tested?
    
    - `./gradlew spotlessApply`
    - `./gradlew :core:test --tests
    
"org.apache.gravitino.authorization.TestAccessControlManager.testUserPagination"
    --tests
    
"org.apache.gravitino.authorization.TestAccessControlManager.testGroupPagination"
    -PskipITs`
    - `./gradlew :core:test --tests
    
"org.apache.gravitino.storage.relational.service.TestUserMetaService.testUserPagination"
    --tests
    
"org.apache.gravitino.storage.relational.service.TestGroupMetaService.testGroupPagination"
    -PskipITs`
    - `./gradlew :core:test --tests
    "org.apache.gravitino.authorization.TestAccessControlManager" -PskipITs`
    - `./gradlew :core:test --tests
    "org.apache.gravitino.audit.v2.TestCompatibilityUtils" -PskipITs`
    
    ---------
    
    Co-authored-by: Cursor <[email protected]>
---
 .../gravitino/audit/v2/CompatibilityUtils.java     |   4 +
 .../authorization/AccessControlDispatcher.java     |  41 +++++++
 .../authorization/AccessControlManager.java        |  33 +++++
 .../gravitino/authorization/PagedResult.java       |  58 +++++++++
 .../gravitino/authorization/UserGroupManager.java  |  30 +++++
 .../hook/AccessControlHookDispatcher.java          |  22 ++++
 .../listener/AccessControlEventDispatcher.java     |  87 +++++++++++++
 .../listener/api/event/CountGroupsEvent.java       |  55 +++++++++
 .../api/event/CountGroupsFailureEvent.java         |  43 +++++++
 .../listener/api/event/CountGroupsPreEvent.java    |  42 +++++++
 .../listener/api/event/CountUsersEvent.java        |  55 +++++++++
 .../listener/api/event/CountUsersFailureEvent.java |  43 +++++++
 .../listener/api/event/CountUsersPreEvent.java     |  42 +++++++
 .../listener/api/event/ListGroupsPagedEvent.java   |  88 +++++++++++++
 .../api/event/ListGroupsPagedFailureEvent.java     |  69 +++++++++++
 .../api/event/ListGroupsPagedPreEvent.java         |  67 ++++++++++
 .../listener/api/event/ListUsersPagedEvent.java    |  88 +++++++++++++
 .../api/event/ListUsersPagedFailureEvent.java      |  69 +++++++++++
 .../listener/api/event/ListUsersPagedPreEvent.java |  67 ++++++++++
 .../listener/api/event/OperationType.java          |   4 +
 .../storage/relational/mapper/GroupMetaMapper.java |  13 ++
 .../mapper/GroupMetaSQLProviderFactory.java        |  11 ++
 .../storage/relational/mapper/UserMetaMapper.java  |  11 ++
 .../mapper/UserMetaSQLProviderFactory.java         |  11 ++
 .../provider/base/GroupMetaBaseSQLProvider.java    |  49 ++++++++
 .../provider/base/UserMetaBaseSQLProvider.java     |  49 ++++++++
 .../mapper/provider/h2/GroupMetaH2Provider.java    |  73 ++++++-----
 .../mapper/provider/h2/UserMetaH2Provider.java     |  45 ++++++-
 .../postgresql/GroupMetaPostgreSQLProvider.java    |  41 +++++++
 .../postgresql/UserMetaPostgreSQLProvider.java     |  41 +++++++
 .../relational/service/GroupMetaService.java       |  39 ++++++
 .../relational/service/UserMetaService.java        |  39 ++++++
 .../gravitino/audit/v2/TestCompatibilityUtils.java |   4 +
 .../authorization/TestAccessControlManager.java    |  62 ++++++++++
 .../listener/api/event/TestGroupEvent.java         |  89 ++++++++++++++
 .../listener/api/event/TestUserEvent.java          |  90 ++++++++++++++
 .../provider/h2/TestGroupMetaH2Provider.java       |   7 +-
 .../relational/service/TestGroupMetaService.java   | 131 ++++++++++++++++++++
 .../relational/service/TestUserMetaService.java    | 136 +++++++++++++++++++++
 39 files changed, 1915 insertions(+), 33 deletions(-)

diff --git 
a/core/src/main/java/org/apache/gravitino/audit/v2/CompatibilityUtils.java 
b/core/src/main/java/org/apache/gravitino/audit/v2/CompatibilityUtils.java
index 5814e5ecbc..725bb5dba6 100644
--- a/core/src/main/java/org/apache/gravitino/audit/v2/CompatibilityUtils.java
+++ b/core/src/main/java/org/apache/gravitino/audit/v2/CompatibilityUtils.java
@@ -129,6 +129,8 @@ public class CompatibilityUtils {
           .put(OperationType.ENABLE_USER, Operation.ENABLE_USER)
           .put(OperationType.DISABLE_USER, Operation.DISABLE_USER)
           .put(OperationType.LIST_USERS, Operation.LIST_USERS)
+          .put(OperationType.LIST_USERS_PAGED, Operation.LIST_USERS)
+          .put(OperationType.COUNT_USERS, Operation.LIST_USERS)
           .put(OperationType.LIST_USER_NAMES, Operation.LIST_USER_NAMES)
           .put(OperationType.GRANT_USER_ROLES, Operation.GRANT_USER_ROLES)
           .put(OperationType.REVOKE_USER_ROLES, Operation.REVOKE_USER_ROLES)
@@ -141,6 +143,8 @@ public class CompatibilityUtils {
           .put(OperationType.REMOVE_GROUP_BY_ID, Operation.REMOVE_GROUP_BY_ID)
           .put(OperationType.ALTER_GROUP, Operation.ALTER_GROUP)
           .put(OperationType.LIST_GROUPS, Operation.LIST_GROUPS)
+          .put(OperationType.LIST_GROUPS_PAGED, Operation.LIST_GROUPS)
+          .put(OperationType.COUNT_GROUPS, Operation.LIST_GROUPS)
           .put(OperationType.LIST_GROUP_NAMES, Operation.LIST_GROUP_NAMES)
           .put(OperationType.GRANT_GROUP_ROLES, Operation.GRANT_GROUP_ROLES)
           .put(OperationType.REVOKE_GROUP_ROLES, Operation.REVOKE_GROUP_ROLES)
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 5f0a1bd462..25b48b1067 100644
--- 
a/core/src/main/java/org/apache/gravitino/authorization/AccessControlDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/AccessControlDispatcher.java
@@ -170,6 +170,27 @@ public interface AccessControlDispatcher {
    */
   User[] listUsers(String metalake) throws NoSuchMetalakeException;
 
+  /**
+   * Lists users with pagination.
+   *
+   * @param metalake The Metalake of the User.
+   * @param offset The number of users to skip.
+   * @param limit The maximum number of users to return.
+   * @return The paginated User result.
+   * @throws NoSuchMetalakeException If the Metalake with the given name does 
not exist.
+   */
+  PagedResult<User> listUsers(String metalake, int offset, int limit)
+      throws NoSuchMetalakeException;
+
+  /**
+   * Counts users in a metalake.
+   *
+   * @param metalake The Metalake of the User.
+   * @return The total number of users.
+   * @throws NoSuchMetalakeException If the Metalake with the given name does 
not exist.
+   */
+  long countUsers(String metalake) throws NoSuchMetalakeException;
+
   /**
    * Lists the usernames.
    *
@@ -312,6 +333,26 @@ public interface AccessControlDispatcher {
    */
   Group[] listGroups(String metalake);
 
+  /**
+   * Lists groups with pagination.
+   *
+   * @param metalake The Metalake of the Group.
+   * @param offset The number of groups to skip.
+   * @param limit The maximum number of groups to return.
+   * @return The paginated Group result.
+   * @throws NoSuchMetalakeException If the Metalake with the given name does 
not exist.
+   */
+  PagedResult<Group> listGroups(String metalake, int offset, int limit);
+
+  /**
+   * Counts groups in a metalake.
+   *
+   * @param metalake The Metalake of the Group.
+   * @return The total number of groups.
+   * @throws NoSuchMetalakeException If the Metalake with the given name does 
not exist.
+   */
+  long countGroups(String metalake);
+
   /**
    * List group names
    *
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 36cd4443b9..91dc0d1781 100644
--- 
a/core/src/main/java/org/apache/gravitino/authorization/AccessControlManager.java
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/AccessControlManager.java
@@ -157,6 +157,23 @@ public class AccessControlManager implements 
AccessControlDispatcher {
         () -> userGroupManager.listUsers(metalake));
   }
 
+  @Override
+  public PagedResult<User> listUsers(String metalake, int offset, int limit)
+      throws NoSuchMetalakeException {
+    return TreeLockUtils.doWithTreeLock(
+        
NameIdentifier.of(AuthorizationUtils.ofUserNamespace(metalake).levels()),
+        LockType.READ,
+        () -> userGroupManager.listUsers(metalake, offset, limit));
+  }
+
+  @Override
+  public long countUsers(String metalake) throws NoSuchMetalakeException {
+    return TreeLockUtils.doWithTreeLock(
+        
NameIdentifier.of(AuthorizationUtils.ofUserNamespace(metalake).levels()),
+        LockType.READ,
+        () -> userGroupManager.countUsers(metalake));
+  }
+
   @Override
   public Group addGroup(String metalake, String group)
       throws GroupAlreadyExistsException, NoSuchMetalakeException {
@@ -244,6 +261,22 @@ public class AccessControlManager implements 
AccessControlDispatcher {
         () -> userGroupManager.listGroups(metalake));
   }
 
+  @Override
+  public PagedResult<Group> listGroups(String metalake, int offset, int limit) 
{
+    return TreeLockUtils.doWithTreeLock(
+        
NameIdentifier.of(AuthorizationUtils.ofGroupNamespace(metalake).levels()),
+        LockType.READ,
+        () -> userGroupManager.listGroups(metalake, offset, limit));
+  }
+
+  @Override
+  public long countGroups(String metalake) {
+    return TreeLockUtils.doWithTreeLock(
+        
NameIdentifier.of(AuthorizationUtils.ofGroupNamespace(metalake).levels()),
+        LockType.READ,
+        () -> userGroupManager.countGroups(metalake));
+  }
+
   @Override
   public String[] listGroupNames(String metalake) throws 
NoSuchMetalakeException {
     return TreeLockUtils.doWithTreeLock(
diff --git 
a/core/src/main/java/org/apache/gravitino/authorization/PagedResult.java 
b/core/src/main/java/org/apache/gravitino/authorization/PagedResult.java
new file mode 100644
index 0000000000..1d525eef71
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/authorization/PagedResult.java
@@ -0,0 +1,58 @@
+/*
+ * 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.authorization;
+
+import java.util.Collections;
+import java.util.List;
+
+/** A paginated query result containing the total count and a page of items. */
+public final class PagedResult<T> {
+
+  private final long totalCount;
+  private final List<T> items;
+
+  /**
+   * Creates a paginated result.
+   *
+   * @param totalCount The total number of matching items.
+   * @param items The items in the current page.
+   */
+  public PagedResult(long totalCount, List<T> items) {
+    this.totalCount = totalCount;
+    this.items = items != null ? items : Collections.emptyList();
+  }
+
+  /**
+   * Returns the total number of matching items.
+   *
+   * @return The total count.
+   */
+  public long totalCount() {
+    return totalCount;
+  }
+
+  /**
+   * Returns the items in the current page.
+   *
+   * @return The page items.
+   */
+  public List<T> items() {
+    return items;
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/authorization/UserGroupManager.java 
b/core/src/main/java/org/apache/gravitino/authorization/UserGroupManager.java
index d6dc2b0636..be0d56879e 100644
--- 
a/core/src/main/java/org/apache/gravitino/authorization/UserGroupManager.java
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/UserGroupManager.java
@@ -27,6 +27,7 @@ import org.apache.gravitino.Entity;
 import org.apache.gravitino.Entity.EntityType;
 import org.apache.gravitino.EntityAlreadyExistsException;
 import org.apache.gravitino.EntityStore;
+import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.exceptions.GroupAlreadyExistsException;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
@@ -37,7 +38,10 @@ import 
org.apache.gravitino.exceptions.UserAlreadyExistsException;
 import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.GroupEntity;
 import org.apache.gravitino.meta.UserEntity;
+import org.apache.gravitino.metalake.MetalakeManager;
 import org.apache.gravitino.storage.IdGenerator;
+import org.apache.gravitino.storage.relational.service.GroupMetaService;
+import org.apache.gravitino.storage.relational.service.UserMetaService;
 import org.apache.gravitino.utils.PrincipalUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -121,6 +125,19 @@ class UserGroupManager {
     return listUsersInternal(metalake, true /* allFields */);
   }
 
+  long countUsers(String metalake) {
+    MetalakeManager.checkMetalake(NameIdentifier.of(metalake), store);
+    return UserMetaService.getInstance().countUsersByMetalake(metalake);
+  }
+
+  PagedResult<User> listUsers(String metalake, int offset, int limit) {
+    MetalakeManager.checkMetalake(NameIdentifier.of(metalake), store);
+    PagedResult<UserEntity> result =
+        UserMetaService.getInstance().listUsersByMetalakePaginated(metalake, 
offset, limit);
+    return new PagedResult<>(
+        result.totalCount(), Arrays.asList(result.items().toArray(new 
User[0])));
+  }
+
   Group addGroup(String metalake, String group) throws 
GroupAlreadyExistsException {
     try {
       GroupEntity groupEntity =
@@ -178,6 +195,19 @@ class UserGroupManager {
     return listGroupInternal(metalake, true);
   }
 
+  long countGroups(String metalake) {
+    MetalakeManager.checkMetalake(NameIdentifier.of(metalake), store);
+    return GroupMetaService.getInstance().countGroupsByMetalake(metalake);
+  }
+
+  PagedResult<Group> listGroups(String metalake, int offset, int limit) {
+    MetalakeManager.checkMetalake(NameIdentifier.of(metalake), store);
+    PagedResult<GroupEntity> result =
+        GroupMetaService.getInstance().listGroupsByMetalakePaginated(metalake, 
offset, limit);
+    return new PagedResult<>(
+        result.totalCount(), Arrays.asList(result.items().toArray(new 
Group[0])));
+  }
+
   String[] listGroupNames(String metalake) {
     return Arrays.stream(listGroupInternal(metalake, false))
         .map(Group::name)
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 92c6f47f1b..3d856a61f8 100644
--- 
a/core/src/main/java/org/apache/gravitino/hook/AccessControlHookDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/hook/AccessControlHookDispatcher.java
@@ -31,6 +31,7 @@ import org.apache.gravitino.authorization.Group;
 import org.apache.gravitino.authorization.GroupChange;
 import org.apache.gravitino.authorization.Owner;
 import org.apache.gravitino.authorization.OwnerDispatcher;
+import org.apache.gravitino.authorization.PagedResult;
 import org.apache.gravitino.authorization.Privilege;
 import org.apache.gravitino.authorization.Role;
 import org.apache.gravitino.authorization.SecurableObject;
@@ -122,6 +123,17 @@ public class AccessControlHookDispatcher implements 
AccessControlDispatcher {
     return dispatcher.listUsers(metalake);
   }
 
+  @Override
+  public PagedResult<User> listUsers(String metalake, int offset, int limit)
+      throws NoSuchMetalakeException {
+    return dispatcher.listUsers(metalake, offset, limit);
+  }
+
+  @Override
+  public long countUsers(String metalake) throws NoSuchMetalakeException {
+    return dispatcher.countUsers(metalake);
+  }
+
   @Override
   public String[] listUserNames(String metalake) throws 
NoSuchMetalakeException {
     return dispatcher.listUserNames(metalake);
@@ -184,6 +196,16 @@ public class AccessControlHookDispatcher implements 
AccessControlDispatcher {
     return dispatcher.listGroups(metalake);
   }
 
+  @Override
+  public PagedResult<Group> listGroups(String metalake, int offset, int limit) 
{
+    return dispatcher.listGroups(metalake, offset, limit);
+  }
+
+  @Override
+  public long countGroups(String metalake) {
+    return dispatcher.countGroups(metalake);
+  }
+
   @Override
   public String[] listGroupNames(String metalake) throws 
NoSuchMetalakeException {
     return dispatcher.listGroupNames(metalake);
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/AccessControlEventDispatcher.java
 
b/core/src/main/java/org/apache/gravitino/listener/AccessControlEventDispatcher.java
index db94a7e989..a0e2517b1a 100644
--- 
a/core/src/main/java/org/apache/gravitino/listener/AccessControlEventDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/listener/AccessControlEventDispatcher.java
@@ -26,6 +26,7 @@ import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.authorization.AccessControlDispatcher;
 import org.apache.gravitino.authorization.Group;
 import org.apache.gravitino.authorization.GroupChange;
+import org.apache.gravitino.authorization.PagedResult;
 import org.apache.gravitino.authorization.Privilege;
 import org.apache.gravitino.authorization.Role;
 import org.apache.gravitino.authorization.SecurableObject;
@@ -52,6 +53,12 @@ import 
org.apache.gravitino.listener.api.event.AlterGroupPreEvent;
 import org.apache.gravitino.listener.api.event.AlterUserEvent;
 import org.apache.gravitino.listener.api.event.AlterUserFailureEvent;
 import org.apache.gravitino.listener.api.event.AlterUserPreEvent;
+import org.apache.gravitino.listener.api.event.CountGroupsEvent;
+import org.apache.gravitino.listener.api.event.CountGroupsFailureEvent;
+import org.apache.gravitino.listener.api.event.CountGroupsPreEvent;
+import org.apache.gravitino.listener.api.event.CountUsersEvent;
+import org.apache.gravitino.listener.api.event.CountUsersFailureEvent;
+import org.apache.gravitino.listener.api.event.CountUsersPreEvent;
 import org.apache.gravitino.listener.api.event.CreateRoleEvent;
 import org.apache.gravitino.listener.api.event.CreateRoleFailureEvent;
 import org.apache.gravitino.listener.api.event.CreateRolePreEvent;
@@ -93,6 +100,9 @@ import 
org.apache.gravitino.listener.api.event.ListGroupNamesFailureEvent;
 import org.apache.gravitino.listener.api.event.ListGroupNamesPreEvent;
 import org.apache.gravitino.listener.api.event.ListGroupsEvent;
 import org.apache.gravitino.listener.api.event.ListGroupsFailureEvent;
+import org.apache.gravitino.listener.api.event.ListGroupsPagedEvent;
+import org.apache.gravitino.listener.api.event.ListGroupsPagedFailureEvent;
+import org.apache.gravitino.listener.api.event.ListGroupsPagedPreEvent;
 import org.apache.gravitino.listener.api.event.ListGroupsPreEvent;
 import org.apache.gravitino.listener.api.event.ListRoleNamesEvent;
 import org.apache.gravitino.listener.api.event.ListRoleNamesFailureEvent;
@@ -102,6 +112,9 @@ import 
org.apache.gravitino.listener.api.event.ListUserNamesFailureEvent;
 import org.apache.gravitino.listener.api.event.ListUserNamesPreEvent;
 import org.apache.gravitino.listener.api.event.ListUsersEvent;
 import org.apache.gravitino.listener.api.event.ListUsersFailureEvent;
+import org.apache.gravitino.listener.api.event.ListUsersPagedEvent;
+import org.apache.gravitino.listener.api.event.ListUsersPagedFailureEvent;
+import org.apache.gravitino.listener.api.event.ListUsersPagedPreEvent;
 import org.apache.gravitino.listener.api.event.ListUsersPreEvent;
 import org.apache.gravitino.listener.api.event.OverridePrivilegesEvent;
 import org.apache.gravitino.listener.api.event.OverridePrivilegesFailureEvent;
@@ -342,6 +355,43 @@ public class AccessControlEventDispatcher implements 
AccessControlDispatcher {
     }
   }
 
+  /** {@inheritDoc} */
+  @Override
+  public PagedResult<User> listUsers(String metalake, int offset, int limit)
+      throws NoSuchMetalakeException {
+    String initiator = PrincipalUtils.getCurrentUserName();
+
+    eventBus.dispatchEvent(new ListUsersPagedPreEvent(initiator, metalake, 
offset, limit));
+    try {
+      PagedResult<User> users = dispatcher.listUsers(metalake, offset, limit);
+      eventBus.dispatchEvent(
+          new ListUsersPagedEvent(
+              initiator, metalake, offset, limit, users.items().size(), 
users.totalCount()));
+
+      return users;
+    } catch (Exception e) {
+      eventBus.dispatchEvent(new ListUsersPagedFailureEvent(initiator, 
metalake, e, offset, limit));
+      throw e;
+    }
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public long countUsers(String metalake) throws NoSuchMetalakeException {
+    String initiator = PrincipalUtils.getCurrentUserName();
+
+    eventBus.dispatchEvent(new CountUsersPreEvent(initiator, metalake));
+    try {
+      long count = dispatcher.countUsers(metalake);
+      eventBus.dispatchEvent(new CountUsersEvent(initiator, metalake, count));
+
+      return count;
+    } catch (Exception e) {
+      eventBus.dispatchEvent(new CountUsersFailureEvent(initiator, metalake, 
e));
+      throw e;
+    }
+  }
+
   /** {@inheritDoc} */
   @Override
   public String[] listUserNames(String metalake) throws 
NoSuchMetalakeException {
@@ -544,6 +594,43 @@ public class AccessControlEventDispatcher implements 
AccessControlDispatcher {
     }
   }
 
+  /** {@inheritDoc} */
+  @Override
+  public PagedResult<Group> listGroups(String metalake, int offset, int limit) 
{
+    String initiator = PrincipalUtils.getCurrentUserName();
+
+    eventBus.dispatchEvent(new ListGroupsPagedPreEvent(initiator, metalake, 
offset, limit));
+    try {
+      PagedResult<Group> groups = dispatcher.listGroups(metalake, offset, 
limit);
+      eventBus.dispatchEvent(
+          new ListGroupsPagedEvent(
+              initiator, metalake, offset, limit, groups.items().size(), 
groups.totalCount()));
+
+      return groups;
+    } catch (Exception e) {
+      eventBus.dispatchEvent(
+          new ListGroupsPagedFailureEvent(initiator, metalake, e, offset, 
limit));
+      throw e;
+    }
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public long countGroups(String metalake) {
+    String initiator = PrincipalUtils.getCurrentUserName();
+
+    eventBus.dispatchEvent(new CountGroupsPreEvent(initiator, metalake));
+    try {
+      long count = dispatcher.countGroups(metalake);
+      eventBus.dispatchEvent(new CountGroupsEvent(initiator, metalake, count));
+
+      return count;
+    } catch (Exception e) {
+      eventBus.dispatchEvent(new CountGroupsFailureEvent(initiator, metalake, 
e));
+      throw e;
+    }
+  }
+
   /** {@inheritDoc} */
   @Override
   public String[] listGroupNames(String metalake) {
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/event/CountGroupsEvent.java
 
b/core/src/main/java/org/apache/gravitino/listener/api/event/CountGroupsEvent.java
new file mode 100644
index 0000000000..c29712df64
--- /dev/null
+++ 
b/core/src/main/java/org/apache/gravitino/listener/api/event/CountGroupsEvent.java
@@ -0,0 +1,55 @@
+/*
+ * 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.listener.api.event;
+
+import org.apache.gravitino.annotation.DeveloperApi;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+
+/** Represents an event triggered after successfully counting groups in a 
metalake. */
+@DeveloperApi
+public class CountGroupsEvent extends GroupEvent {
+
+  private final long count;
+
+  /**
+   * Creates a new {@link CountGroupsEvent}.
+   *
+   * @param initiator the user who initiated the request.
+   * @param metalake the metalake name.
+   * @param count the total number of groups.
+   */
+  public CountGroupsEvent(String initiator, String metalake, long count) {
+    super(initiator, NameIdentifierUtil.ofMetalake(metalake));
+    this.count = count;
+  }
+
+  /**
+   * Returns the total number of groups.
+   *
+   * @return the group count.
+   */
+  public long count() {
+    return count;
+  }
+
+  @Override
+  public OperationType operationType() {
+    return OperationType.COUNT_GROUPS;
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/event/CountGroupsFailureEvent.java
 
b/core/src/main/java/org/apache/gravitino/listener/api/event/CountGroupsFailureEvent.java
new file mode 100644
index 0000000000..a8d31d849d
--- /dev/null
+++ 
b/core/src/main/java/org/apache/gravitino/listener/api/event/CountGroupsFailureEvent.java
@@ -0,0 +1,43 @@
+/*
+ * 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.listener.api.event;
+
+import org.apache.gravitino.annotation.DeveloperApi;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+
+/** Represents an event triggered when counting groups fails. */
+@DeveloperApi
+public class CountGroupsFailureEvent extends GroupFailureEvent {
+
+  /**
+   * Creates a new {@link CountGroupsFailureEvent}.
+   *
+   * @param initiator the user who initiated the operation.
+   * @param metalake the metalake name.
+   * @param exception the exception encountered.
+   */
+  public CountGroupsFailureEvent(String initiator, String metalake, Exception 
exception) {
+    super(initiator, NameIdentifierUtil.ofMetalake(metalake), exception);
+  }
+
+  @Override
+  public OperationType operationType() {
+    return OperationType.COUNT_GROUPS;
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/event/CountGroupsPreEvent.java
 
b/core/src/main/java/org/apache/gravitino/listener/api/event/CountGroupsPreEvent.java
new file mode 100644
index 0000000000..888e0d6c20
--- /dev/null
+++ 
b/core/src/main/java/org/apache/gravitino/listener/api/event/CountGroupsPreEvent.java
@@ -0,0 +1,42 @@
+/*
+ * 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.listener.api.event;
+
+import org.apache.gravitino.annotation.DeveloperApi;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+
+/** Represents an event triggered before counting groups in a metalake. */
+@DeveloperApi
+public class CountGroupsPreEvent extends GroupPreEvent {
+
+  /**
+   * Creates a new {@link CountGroupsPreEvent}.
+   *
+   * @param initiator the user who initiated the request.
+   * @param metalake the metalake name.
+   */
+  public CountGroupsPreEvent(String initiator, String metalake) {
+    super(initiator, NameIdentifierUtil.ofMetalake(metalake));
+  }
+
+  @Override
+  public OperationType operationType() {
+    return OperationType.COUNT_GROUPS;
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/event/CountUsersEvent.java
 
b/core/src/main/java/org/apache/gravitino/listener/api/event/CountUsersEvent.java
new file mode 100644
index 0000000000..f63f2cd097
--- /dev/null
+++ 
b/core/src/main/java/org/apache/gravitino/listener/api/event/CountUsersEvent.java
@@ -0,0 +1,55 @@
+/*
+ * 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.listener.api.event;
+
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.annotation.DeveloperApi;
+
+/** Represents an event triggered after successfully counting users in a 
metalake. */
+@DeveloperApi
+public class CountUsersEvent extends UserEvent {
+
+  private final long count;
+
+  /**
+   * Creates a new {@link CountUsersEvent}.
+   *
+   * @param initiator the user who initiated the request.
+   * @param metalake the metalake name.
+   * @param count the total number of users.
+   */
+  public CountUsersEvent(String initiator, String metalake, long count) {
+    super(initiator, NameIdentifier.of(metalake));
+    this.count = count;
+  }
+
+  /**
+   * Returns the total number of users.
+   *
+   * @return the user count.
+   */
+  public long count() {
+    return count;
+  }
+
+  @Override
+  public OperationType operationType() {
+    return OperationType.COUNT_USERS;
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/event/CountUsersFailureEvent.java
 
b/core/src/main/java/org/apache/gravitino/listener/api/event/CountUsersFailureEvent.java
new file mode 100644
index 0000000000..6737415a71
--- /dev/null
+++ 
b/core/src/main/java/org/apache/gravitino/listener/api/event/CountUsersFailureEvent.java
@@ -0,0 +1,43 @@
+/*
+ * 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.listener.api.event;
+
+import org.apache.gravitino.annotation.DeveloperApi;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+
+/** Represents an event triggered when counting users fails. */
+@DeveloperApi
+public class CountUsersFailureEvent extends UserFailureEvent {
+
+  /**
+   * Creates a new {@link CountUsersFailureEvent}.
+   *
+   * @param user the user who initiated the operation.
+   * @param metalake the metalake name.
+   * @param exception the exception encountered.
+   */
+  public CountUsersFailureEvent(String user, String metalake, Exception 
exception) {
+    super(user, NameIdentifierUtil.ofMetalake(metalake), exception);
+  }
+
+  @Override
+  public OperationType operationType() {
+    return OperationType.COUNT_USERS;
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/event/CountUsersPreEvent.java
 
b/core/src/main/java/org/apache/gravitino/listener/api/event/CountUsersPreEvent.java
new file mode 100644
index 0000000000..e32e66a814
--- /dev/null
+++ 
b/core/src/main/java/org/apache/gravitino/listener/api/event/CountUsersPreEvent.java
@@ -0,0 +1,42 @@
+/*
+ * 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.listener.api.event;
+
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.annotation.DeveloperApi;
+
+/** Represents an event triggered before counting users in a metalake. */
+@DeveloperApi
+public class CountUsersPreEvent extends UserPreEvent {
+
+  /**
+   * Creates a new {@link CountUsersPreEvent}.
+   *
+   * @param initiator the user who initiated the request.
+   * @param metalake the metalake name.
+   */
+  public CountUsersPreEvent(String initiator, String metalake) {
+    super(initiator, NameIdentifier.of(metalake));
+  }
+
+  @Override
+  public OperationType operationType() {
+    return OperationType.COUNT_USERS;
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/event/ListGroupsPagedEvent.java
 
b/core/src/main/java/org/apache/gravitino/listener/api/event/ListGroupsPagedEvent.java
new file mode 100644
index 0000000000..36f9b71b41
--- /dev/null
+++ 
b/core/src/main/java/org/apache/gravitino/listener/api/event/ListGroupsPagedEvent.java
@@ -0,0 +1,88 @@
+/*
+ * 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.listener.api.event;
+
+import org.apache.gravitino.annotation.DeveloperApi;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+
+/** Represents an event triggered after successfully listing groups with 
pagination. */
+@DeveloperApi
+public class ListGroupsPagedEvent extends GroupEvent implements ListEvent {
+
+  private final int offset;
+  private final int limit;
+  private final int pageSize;
+  private final long totalCount;
+
+  /**
+   * Creates a new {@link ListGroupsPagedEvent}.
+   *
+   * @param initiator the user who initiated the request.
+   * @param metalake the metalake name.
+   * @param offset the number of groups skipped.
+   * @param limit the requested page size limit.
+   * @param pageSize the number of groups returned in this page.
+   * @param totalCount the total number of groups in the metalake.
+   */
+  public ListGroupsPagedEvent(
+      String initiator, String metalake, int offset, int limit, int pageSize, 
long totalCount) {
+    super(initiator, NameIdentifierUtil.ofMetalake(metalake));
+    this.offset = offset;
+    this.limit = limit;
+    this.pageSize = pageSize;
+    this.totalCount = totalCount;
+  }
+
+  /**
+   * Returns the pagination offset.
+   *
+   * @return the offset.
+   */
+  public int offset() {
+    return offset;
+  }
+
+  /**
+   * Returns the pagination limit.
+   *
+   * @return the limit.
+   */
+  public int limit() {
+    return limit;
+  }
+
+  /**
+   * Returns the total number of groups in the metalake.
+   *
+   * @return the total count.
+   */
+  public long totalCount() {
+    return totalCount;
+  }
+
+  @Override
+  public int resultCount() {
+    return pageSize;
+  }
+
+  @Override
+  public OperationType operationType() {
+    return OperationType.LIST_GROUPS_PAGED;
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/event/ListGroupsPagedFailureEvent.java
 
b/core/src/main/java/org/apache/gravitino/listener/api/event/ListGroupsPagedFailureEvent.java
new file mode 100644
index 0000000000..71b3ed5e19
--- /dev/null
+++ 
b/core/src/main/java/org/apache/gravitino/listener/api/event/ListGroupsPagedFailureEvent.java
@@ -0,0 +1,69 @@
+/*
+ * 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.listener.api.event;
+
+import org.apache.gravitino.annotation.DeveloperApi;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+
+/** Represents an event triggered when a paginated group list operation fails. 
*/
+@DeveloperApi
+public class ListGroupsPagedFailureEvent extends GroupFailureEvent {
+
+  private final int offset;
+  private final int limit;
+
+  /**
+   * Creates a new {@link ListGroupsPagedFailureEvent}.
+   *
+   * @param initiator the user who initiated the operation.
+   * @param metalake the metalake name.
+   * @param exception the exception encountered.
+   * @param offset the pagination offset.
+   * @param limit the pagination limit.
+   */
+  public ListGroupsPagedFailureEvent(
+      String initiator, String metalake, Exception exception, int offset, int 
limit) {
+    super(initiator, NameIdentifierUtil.ofMetalake(metalake), exception);
+    this.offset = offset;
+    this.limit = limit;
+  }
+
+  /**
+   * Returns the pagination offset.
+   *
+   * @return the offset.
+   */
+  public int offset() {
+    return offset;
+  }
+
+  /**
+   * Returns the pagination limit.
+   *
+   * @return the limit.
+   */
+  public int limit() {
+    return limit;
+  }
+
+  @Override
+  public OperationType operationType() {
+    return OperationType.LIST_GROUPS_PAGED;
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/event/ListGroupsPagedPreEvent.java
 
b/core/src/main/java/org/apache/gravitino/listener/api/event/ListGroupsPagedPreEvent.java
new file mode 100644
index 0000000000..20acd6cc8a
--- /dev/null
+++ 
b/core/src/main/java/org/apache/gravitino/listener/api/event/ListGroupsPagedPreEvent.java
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.listener.api.event;
+
+import org.apache.gravitino.annotation.DeveloperApi;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+
+/** Represents an event triggered before listing groups with pagination. */
+@DeveloperApi
+public class ListGroupsPagedPreEvent extends GroupPreEvent {
+
+  private final int offset;
+  private final int limit;
+
+  /**
+   * Creates a new {@link ListGroupsPagedPreEvent}.
+   *
+   * @param initiator the user who initiated the request.
+   * @param metalake the metalake name.
+   * @param offset the number of groups to skip.
+   * @param limit the maximum number of groups to return.
+   */
+  public ListGroupsPagedPreEvent(String initiator, String metalake, int 
offset, int limit) {
+    super(initiator, NameIdentifierUtil.ofMetalake(metalake));
+    this.offset = offset;
+    this.limit = limit;
+  }
+
+  /**
+   * Returns the pagination offset.
+   *
+   * @return the offset.
+   */
+  public int offset() {
+    return offset;
+  }
+
+  /**
+   * Returns the pagination limit.
+   *
+   * @return the limit.
+   */
+  public int limit() {
+    return limit;
+  }
+
+  @Override
+  public OperationType operationType() {
+    return OperationType.LIST_GROUPS_PAGED;
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/event/ListUsersPagedEvent.java
 
b/core/src/main/java/org/apache/gravitino/listener/api/event/ListUsersPagedEvent.java
new file mode 100644
index 0000000000..3e62eebac2
--- /dev/null
+++ 
b/core/src/main/java/org/apache/gravitino/listener/api/event/ListUsersPagedEvent.java
@@ -0,0 +1,88 @@
+/*
+ * 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.listener.api.event;
+
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.annotation.DeveloperApi;
+
+/** Represents an event triggered after successfully listing users with 
pagination. */
+@DeveloperApi
+public class ListUsersPagedEvent extends UserEvent implements ListEvent {
+
+  private final int offset;
+  private final int limit;
+  private final int pageSize;
+  private final long totalCount;
+
+  /**
+   * Creates a new {@link ListUsersPagedEvent}.
+   *
+   * @param initiator the user who initiated the request.
+   * @param metalake the metalake name.
+   * @param offset the number of users skipped.
+   * @param limit the requested page size limit.
+   * @param pageSize the number of users returned in this page.
+   * @param totalCount the total number of users in the metalake.
+   */
+  public ListUsersPagedEvent(
+      String initiator, String metalake, int offset, int limit, int pageSize, 
long totalCount) {
+    super(initiator, NameIdentifier.of(metalake));
+    this.offset = offset;
+    this.limit = limit;
+    this.pageSize = pageSize;
+    this.totalCount = totalCount;
+  }
+
+  /**
+   * Returns the pagination offset.
+   *
+   * @return the offset.
+   */
+  public int offset() {
+    return offset;
+  }
+
+  /**
+   * Returns the pagination limit.
+   *
+   * @return the limit.
+   */
+  public int limit() {
+    return limit;
+  }
+
+  /**
+   * Returns the total number of users in the metalake.
+   *
+   * @return the total count.
+   */
+  public long totalCount() {
+    return totalCount;
+  }
+
+  @Override
+  public int resultCount() {
+    return pageSize;
+  }
+
+  @Override
+  public OperationType operationType() {
+    return OperationType.LIST_USERS_PAGED;
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/event/ListUsersPagedFailureEvent.java
 
b/core/src/main/java/org/apache/gravitino/listener/api/event/ListUsersPagedFailureEvent.java
new file mode 100644
index 0000000000..9aa46f5584
--- /dev/null
+++ 
b/core/src/main/java/org/apache/gravitino/listener/api/event/ListUsersPagedFailureEvent.java
@@ -0,0 +1,69 @@
+/*
+ * 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.listener.api.event;
+
+import org.apache.gravitino.annotation.DeveloperApi;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+
+/** Represents an event triggered when a paginated user list operation fails. 
*/
+@DeveloperApi
+public class ListUsersPagedFailureEvent extends UserFailureEvent {
+
+  private final int offset;
+  private final int limit;
+
+  /**
+   * Creates a new {@link ListUsersPagedFailureEvent}.
+   *
+   * @param user the user who initiated the operation.
+   * @param metalake the metalake name.
+   * @param exception the exception encountered.
+   * @param offset the pagination offset.
+   * @param limit the pagination limit.
+   */
+  public ListUsersPagedFailureEvent(
+      String user, String metalake, Exception exception, int offset, int 
limit) {
+    super(user, NameIdentifierUtil.ofMetalake(metalake), exception);
+    this.offset = offset;
+    this.limit = limit;
+  }
+
+  /**
+   * Returns the pagination offset.
+   *
+   * @return the offset.
+   */
+  public int offset() {
+    return offset;
+  }
+
+  /**
+   * Returns the pagination limit.
+   *
+   * @return the limit.
+   */
+  public int limit() {
+    return limit;
+  }
+
+  @Override
+  public OperationType operationType() {
+    return OperationType.LIST_USERS_PAGED;
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/event/ListUsersPagedPreEvent.java
 
b/core/src/main/java/org/apache/gravitino/listener/api/event/ListUsersPagedPreEvent.java
new file mode 100644
index 0000000000..4527e422cd
--- /dev/null
+++ 
b/core/src/main/java/org/apache/gravitino/listener/api/event/ListUsersPagedPreEvent.java
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.listener.api.event;
+
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.annotation.DeveloperApi;
+
+/** Represents an event triggered before listing users with pagination. */
+@DeveloperApi
+public class ListUsersPagedPreEvent extends UserPreEvent {
+
+  private final int offset;
+  private final int limit;
+
+  /**
+   * Creates a new {@link ListUsersPagedPreEvent}.
+   *
+   * @param initiator the user who initiated the request.
+   * @param metalake the metalake name.
+   * @param offset the number of users to skip.
+   * @param limit the maximum number of users to return.
+   */
+  public ListUsersPagedPreEvent(String initiator, String metalake, int offset, 
int limit) {
+    super(initiator, NameIdentifier.of(metalake));
+    this.offset = offset;
+    this.limit = limit;
+  }
+
+  /**
+   * Returns the pagination offset.
+   *
+   * @return the offset.
+   */
+  public int offset() {
+    return offset;
+  }
+
+  /**
+   * Returns the pagination limit.
+   *
+   * @return the limit.
+   */
+  public int limit() {
+    return limit;
+  }
+
+  @Override
+  public OperationType operationType() {
+    return OperationType.LIST_USERS_PAGED;
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/event/OperationType.java 
b/core/src/main/java/org/apache/gravitino/listener/api/event/OperationType.java
index c86c58e1b0..5350c18562 100644
--- 
a/core/src/main/java/org/apache/gravitino/listener/api/event/OperationType.java
+++ 
b/core/src/main/java/org/apache/gravitino/listener/api/event/OperationType.java
@@ -137,6 +137,8 @@ public enum OperationType {
   ENABLE_USER,
   DISABLE_USER,
   LIST_USERS,
+  LIST_USERS_PAGED,
+  COUNT_USERS,
   LIST_USER_NAMES,
   GRANT_USER_ROLES,
   REVOKE_USER_ROLES,
@@ -151,6 +153,8 @@ public enum OperationType {
   REMOVE_GROUP_BY_ID,
   ALTER_GROUP,
   LIST_GROUPS,
+  LIST_GROUPS_PAGED,
+  COUNT_GROUPS,
   LIST_GROUP_NAMES,
   GRANT_GROUP_ROLES,
   REVOKE_GROUP_ROLES,
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/GroupMetaMapper.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/GroupMetaMapper.java
index b74116fb24..8c30152d25 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/GroupMetaMapper.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/GroupMetaMapper.java
@@ -67,6 +67,19 @@ public interface GroupMetaMapper {
       method = "listExtendedGroupPOsByMetalakeId")
   List<ExtendedGroupPO> listExtendedGroupPOsByMetalakeId(@Param("metalakeId") 
Long metalakeId);
 
+  @SelectProvider(
+      type = GroupMetaSQLProviderFactory.class,
+      method = "countGroupMetasByMetalakeName")
+  Long countGroupMetasByMetalakeName(@Param("metalakeName") String 
metalakeName);
+
+  @SelectProvider(
+      type = GroupMetaSQLProviderFactory.class,
+      method = "listExtendedGroupPOsByMetalakeNamePaginated")
+  List<ExtendedGroupPO> listExtendedGroupPOsByMetalakeNamePaginated(
+      @Param("metalakeName") String metalakeName,
+      @Param("offset") int offset,
+      @Param("limit") int limit);
+
   @InsertProvider(type = GroupMetaSQLProviderFactory.class, method = 
"insertGroupMeta")
   void insertGroupMeta(@Param("groupMeta") GroupPO groupPO);
 
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/GroupMetaSQLProviderFactory.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/GroupMetaSQLProviderFactory.java
index 0981d4b401..9fa4eece50 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/GroupMetaSQLProviderFactory.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/GroupMetaSQLProviderFactory.java
@@ -97,6 +97,17 @@ public class GroupMetaSQLProviderFactory {
     return getProvider().listExtendedGroupPOsByMetalakeId(metalakeId);
   }
 
+  public static String countGroupMetasByMetalakeName(@Param("metalakeName") 
String metalakeName) {
+    return getProvider().countGroupMetasByMetalakeName(metalakeName);
+  }
+
+  public static String listExtendedGroupPOsByMetalakeNamePaginated(
+      @Param("metalakeName") String metalakeName,
+      @Param("offset") int offset,
+      @Param("limit") int limit) {
+    return 
getProvider().listExtendedGroupPOsByMetalakeNamePaginated(metalakeName, offset, 
limit);
+  }
+
   public static String deleteGroupMetasByLegacyTimeline(
       @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit) 
{
     return getProvider().deleteGroupMetasByLegacyTimeline(legacyTimeline, 
limit);
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaMapper.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaMapper.java
index 45bcb63c5d..ff503326bb 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaMapper.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaMapper.java
@@ -65,6 +65,17 @@ public interface UserMetaMapper {
       method = "listExtendedUserPOsByMetalakeId")
   List<ExtendedUserPO> listExtendedUserPOsByMetalakeId(@Param("metalakeId") 
Long metalakeId);
 
+  @SelectProvider(type = UserMetaSQLProviderFactory.class, method = 
"countUserMetasByMetalakeName")
+  Long countUserMetasByMetalakeName(@Param("metalakeName") String 
metalakeName);
+
+  @SelectProvider(
+      type = UserMetaSQLProviderFactory.class,
+      method = "listExtendedUserPOsByMetalakeNamePaginated")
+  List<ExtendedUserPO> listExtendedUserPOsByMetalakeNamePaginated(
+      @Param("metalakeName") String metalakeName,
+      @Param("offset") int offset,
+      @Param("limit") int limit);
+
   @InsertProvider(
       type = UserMetaSQLProviderFactory.class,
       method = "insertUserMetaOnDuplicateKeyUpdate")
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaSQLProviderFactory.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaSQLProviderFactory.java
index 9d668dd2f3..57b4afa3cb 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaSQLProviderFactory.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaSQLProviderFactory.java
@@ -94,6 +94,17 @@ public class UserMetaSQLProviderFactory {
     return getProvider().listExtendedUserPOsByMetalakeId(metalakeId);
   }
 
+  public static String countUserMetasByMetalakeName(@Param("metalakeName") 
String metalakeName) {
+    return getProvider().countUserMetasByMetalakeName(metalakeName);
+  }
+
+  public static String listExtendedUserPOsByMetalakeNamePaginated(
+      @Param("metalakeName") String metalakeName,
+      @Param("offset") int offset,
+      @Param("limit") int limit) {
+    return 
getProvider().listExtendedUserPOsByMetalakeNamePaginated(metalakeName, offset, 
limit);
+  }
+
   public static String deleteUserMetasByLegacyTimeline(
       @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit) 
{
     return getProvider().deleteUserMetasByLegacyTimeline(legacyTimeline, 
limit);
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/GroupMetaBaseSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/GroupMetaBaseSQLProvider.java
index fa9085a429..852e48cc42 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/GroupMetaBaseSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/GroupMetaBaseSQLProvider.java
@@ -76,6 +76,55 @@ public class GroupMetaBaseSQLProvider {
         + " GROUP BY gt.group_id";
   }
 
+  public String countGroupMetasByMetalakeName(@Param("metalakeName") String 
metalakeName) {
+    return "SELECT COUNT(*) FROM "
+        + GROUP_TABLE_NAME
+        + " gt JOIN "
+        + MetalakeMetaMapper.TABLE_NAME
+        + " mt ON gt.metalake_id = mt.metalake_id"
+        + " WHERE mt.metalake_name = #{metalakeName}"
+        + " AND gt.deleted_at = 0 AND mt.deleted_at = 0";
+  }
+
+  public String listExtendedGroupPOsByMetalakeNamePaginated(
+      @Param("metalakeName") String metalakeName,
+      @Param("offset") int offset,
+      @Param("limit") int limit) {
+    return "SELECT gt.group_id as groupId, gt.group_name as groupName,"
+        + " gt.metalake_id as metalakeId,"
+        + " gt.external_id as externalId,"
+        + " gt.audit_info as auditInfo,"
+        + " gt.current_version as currentVersion, gt.last_version as 
lastVersion,"
+        + " gt.deleted_at as deletedAt,"
+        + " JSON_ARRAYAGG(rot.role_name) as roleNames,"
+        + " JSON_ARRAYAGG(rot.role_id) as roleIds"
+        + " FROM ("
+        + " SELECT gt.group_id FROM "
+        + GROUP_TABLE_NAME
+        + " gt JOIN "
+        + MetalakeMetaMapper.TABLE_NAME
+        + " mt ON gt.metalake_id = mt.metalake_id"
+        + " WHERE mt.metalake_name = #{metalakeName}"
+        + " AND gt.deleted_at = 0 AND mt.deleted_at = 0"
+        + " ORDER BY gt.group_id ASC LIMIT #{limit} OFFSET #{offset}"
+        + " ) paginated"
+        + " JOIN "
+        + GROUP_TABLE_NAME
+        + " gt ON gt.group_id = paginated.group_id"
+        + " LEFT OUTER JOIN ("
+        + " SELECT * FROM "
+        + GROUP_ROLE_RELATION_TABLE_NAME
+        + " WHERE deleted_at = 0)"
+        + " AS rt ON rt.group_id = gt.group_id"
+        + " LEFT OUTER JOIN ("
+        + " SELECT * FROM "
+        + ROLE_TABLE_NAME
+        + " WHERE deleted_at = 0)"
+        + " AS rot ON rot.role_id = rt.role_id"
+        + " GROUP BY gt.group_id"
+        + " ORDER BY gt.group_id ASC";
+  }
+
   public String selectGroupMetaByMetalakeIdAndName(
       @Param("metalakeId") Long metalakeId, @Param("groupName") String name) {
     return "SELECT group_id as groupId, group_name as groupName,"
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/UserMetaBaseSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/UserMetaBaseSQLProvider.java
index c0647d3855..387c5676e0 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/UserMetaBaseSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/UserMetaBaseSQLProvider.java
@@ -247,6 +247,55 @@ public class UserMetaBaseSQLProvider {
         + " GROUP BY ut.user_id";
   }
 
+  public String countUserMetasByMetalakeName(@Param("metalakeName") String 
metalakeName) {
+    return "SELECT COUNT(*) FROM "
+        + USER_TABLE_NAME
+        + " ut JOIN "
+        + MetalakeMetaMapper.TABLE_NAME
+        + " mt ON ut.metalake_id = mt.metalake_id"
+        + " WHERE mt.metalake_name = #{metalakeName}"
+        + " AND ut.deleted_at = 0 AND mt.deleted_at = 0";
+  }
+
+  public String listExtendedUserPOsByMetalakeNamePaginated(
+      @Param("metalakeName") String metalakeName,
+      @Param("offset") int offset,
+      @Param("limit") int limit) {
+    return "SELECT ut.user_id as userId, ut.user_name as userName,"
+        + " ut.metalake_id as metalakeId,"
+        + " ut.external_id as externalId, ut.enabled as enabled,"
+        + " ut.audit_info as auditInfo,"
+        + " ut.current_version as currentVersion, ut.last_version as 
lastVersion,"
+        + " ut.deleted_at as deletedAt,"
+        + " JSON_ARRAYAGG(rot.role_name) as roleNames,"
+        + " JSON_ARRAYAGG(rot.role_id) as roleIds"
+        + " FROM ("
+        + " SELECT ut.user_id FROM "
+        + USER_TABLE_NAME
+        + " ut JOIN "
+        + MetalakeMetaMapper.TABLE_NAME
+        + " mt ON ut.metalake_id = mt.metalake_id"
+        + " WHERE mt.metalake_name = #{metalakeName}"
+        + " AND ut.deleted_at = 0 AND mt.deleted_at = 0"
+        + " ORDER BY ut.user_id ASC LIMIT #{limit} OFFSET #{offset}"
+        + " ) paginated"
+        + " JOIN "
+        + USER_TABLE_NAME
+        + " ut ON ut.user_id = paginated.user_id"
+        + " LEFT OUTER JOIN ("
+        + " SELECT * FROM "
+        + USER_ROLE_RELATION_TABLE_NAME
+        + " WHERE deleted_at = 0)"
+        + " AS rt ON rt.user_id = ut.user_id"
+        + " LEFT OUTER JOIN ("
+        + " SELECT * FROM "
+        + ROLE_TABLE_NAME
+        + " WHERE deleted_at = 0)"
+        + " AS rot ON rot.role_id = rt.role_id"
+        + " GROUP BY ut.user_id"
+        + " ORDER BY ut.user_id ASC";
+  }
+
   public String deleteUserMetasByLegacyTimeline(
       @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit) 
{
     return "DELETE FROM "
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/h2/GroupMetaH2Provider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/h2/GroupMetaH2Provider.java
index 6f0920de42..950a8a0125 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/h2/GroupMetaH2Provider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/h2/GroupMetaH2Provider.java
@@ -23,6 +23,7 @@ import static 
org.apache.gravitino.storage.relational.mapper.RoleMetaMapper.GROU
 import static 
org.apache.gravitino.storage.relational.mapper.RoleMetaMapper.ROLE_TABLE_NAME;
 
 import java.util.List;
+import org.apache.gravitino.storage.relational.mapper.MetalakeMetaMapper;
 import 
org.apache.gravitino.storage.relational.mapper.provider.base.GroupMetaBaseSQLProvider;
 import org.apache.ibatis.annotations.Param;
 
@@ -35,20 +36,8 @@ public class GroupMetaH2Provider extends 
GroupMetaBaseSQLProvider {
         + " gt.audit_info as auditInfo,"
         + " gt.current_version as currentVersion, gt.last_version as 
lastVersion,"
         + " gt.deleted_at as deletedAt,"
-        + " '[' || COALESCE(GROUP_CONCAT( "
-        + "        CASE "
-        + "          WHEN rot.role_name IS NOT NULL AND rot.role_name <> '' "
-        + "          THEN '\"' || rot.role_name || '\"' "
-        + "          ELSE NULL "
-        + "        END "
-        + "      ), '') || ']' as roleNames, "
-        + " '[' || COALESCE(GROUP_CONCAT( "
-        + "        CASE "
-        + "          WHEN rot.role_id IS NOT NULL "
-        + "          THEN '\"' || rot.role_id || '\"' "
-        + "          ELSE NULL "
-        + "        END "
-        + "      ), '') || ']' as roleIds "
+        + " JSON_ARRAYAGG(rot.role_name) as roleNames,"
+        + " JSON_ARRAYAGG(rot.role_id) as roleIds"
         + " FROM "
         + GROUP_TABLE_NAME
         + " gt LEFT OUTER JOIN ("
@@ -67,6 +56,46 @@ public class GroupMetaH2Provider extends 
GroupMetaBaseSQLProvider {
         + " GROUP BY gt.group_id";
   }
 
+  @Override
+  public String listExtendedGroupPOsByMetalakeNamePaginated(
+      @Param("metalakeName") String metalakeName,
+      @Param("offset") int offset,
+      @Param("limit") int limit) {
+    return "SELECT gt.group_id as groupId, gt.group_name as groupName,"
+        + " gt.metalake_id as metalakeId,"
+        + " gt.external_id as externalId,"
+        + " gt.audit_info as auditInfo,"
+        + " gt.current_version as currentVersion, gt.last_version as 
lastVersion,"
+        + " gt.deleted_at as deletedAt,"
+        + " JSON_ARRAYAGG(rot.role_name) as roleNames,"
+        + " JSON_ARRAYAGG(rot.role_id) as roleIds"
+        + " FROM ("
+        + " SELECT gt.group_id FROM "
+        + GROUP_TABLE_NAME
+        + " gt JOIN "
+        + MetalakeMetaMapper.TABLE_NAME
+        + " mt ON gt.metalake_id = mt.metalake_id"
+        + " WHERE mt.metalake_name = #{metalakeName}"
+        + " AND gt.deleted_at = 0 AND mt.deleted_at = 0"
+        + " ORDER BY gt.group_id ASC LIMIT #{limit} OFFSET #{offset}"
+        + " ) paginated"
+        + " JOIN "
+        + GROUP_TABLE_NAME
+        + " gt ON gt.group_id = paginated.group_id"
+        + " LEFT OUTER JOIN ("
+        + " SELECT * FROM "
+        + GROUP_ROLE_RELATION_TABLE_NAME
+        + " WHERE deleted_at = 0)"
+        + " AS rt ON rt.group_id = gt.group_id"
+        + " LEFT OUTER JOIN ("
+        + " SELECT * FROM "
+        + ROLE_TABLE_NAME
+        + " WHERE deleted_at = 0)"
+        + " AS rot ON rot.role_id = rt.role_id"
+        + " GROUP BY gt.group_id"
+        + " ORDER BY gt.group_id ASC";
+  }
+
   @Override
   public String listExtendedGroupPOsByMetalakeIdAndNames(
       @Param("metalakeId") Long metalakeId, @Param("groupNames") List<String> 
groupNames) {
@@ -77,20 +106,8 @@ public class GroupMetaH2Provider extends 
GroupMetaBaseSQLProvider {
         + " gt.audit_info as auditInfo,"
         + " gt.current_version as currentVersion, gt.last_version as 
lastVersion,"
         + " gt.deleted_at as deletedAt,"
-        + " '[' || COALESCE(GROUP_CONCAT( "
-        + "        CASE "
-        + "          WHEN rot.role_name IS NOT NULL AND rot.role_name &lt;&gt; 
'' "
-        + "          THEN '\"' || rot.role_name || '\"' "
-        + "          ELSE NULL "
-        + "        END "
-        + "      ), '') || ']' as roleNames, "
-        + " '[' || COALESCE(GROUP_CONCAT( "
-        + "        CASE "
-        + "          WHEN rot.role_id IS NOT NULL "
-        + "          THEN '\"' || rot.role_id || '\"' "
-        + "          ELSE NULL "
-        + "        END "
-        + "      ), '') || ']' as roleIds "
+        + " JSON_ARRAYAGG(rot.role_name) as roleNames,"
+        + " JSON_ARRAYAGG(rot.role_id) as roleIds"
         + " FROM "
         + GROUP_TABLE_NAME
         + " gt LEFT OUTER JOIN ("
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/h2/UserMetaH2Provider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/h2/UserMetaH2Provider.java
index 894babf2db..4f85335ab4 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/h2/UserMetaH2Provider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/h2/UserMetaH2Provider.java
@@ -22,6 +22,7 @@ import static 
org.apache.gravitino.storage.relational.mapper.RoleMetaMapper.ROLE
 import static 
org.apache.gravitino.storage.relational.mapper.UserMetaMapper.USER_ROLE_RELATION_TABLE_NAME;
 import static 
org.apache.gravitino.storage.relational.mapper.UserRoleRelMapper.USER_TABLE_NAME;
 
+import org.apache.gravitino.storage.relational.mapper.MetalakeMetaMapper;
 import 
org.apache.gravitino.storage.relational.mapper.provider.base.UserMetaBaseSQLProvider;
 import org.apache.ibatis.annotations.Param;
 
@@ -34,8 +35,8 @@ public class UserMetaH2Provider extends 
UserMetaBaseSQLProvider {
         + " ut.audit_info as auditInfo,"
         + " ut.current_version as currentVersion, ut.last_version as 
lastVersion,"
         + " ut.deleted_at as deletedAt,"
-        + " '[' || GROUP_CONCAT('\"' || rot.role_name || '\"') || ']' as 
roleNames,"
-        + " '[' || GROUP_CONCAT('\"' || rot.role_id || '\"') || ']' as roleIds"
+        + " JSON_ARRAYAGG(rot.role_name) as roleNames,"
+        + " JSON_ARRAYAGG(rot.role_id) as roleIds"
         + " FROM "
         + USER_TABLE_NAME
         + " ut LEFT OUTER JOIN ("
@@ -53,4 +54,44 @@ public class UserMetaH2Provider extends 
UserMetaBaseSQLProvider {
         + " ut.metalake_id = #{metalakeId}"
         + " GROUP BY ut.user_id";
   }
+
+  @Override
+  public String listExtendedUserPOsByMetalakeNamePaginated(
+      @Param("metalakeName") String metalakeName,
+      @Param("offset") int offset,
+      @Param("limit") int limit) {
+    return "SELECT ut.user_id as userId, ut.user_name as userName,"
+        + " ut.metalake_id as metalakeId,"
+        + " ut.external_id as externalId, ut.enabled as enabled,"
+        + " ut.audit_info as auditInfo,"
+        + " ut.current_version as currentVersion, ut.last_version as 
lastVersion,"
+        + " ut.deleted_at as deletedAt,"
+        + " JSON_ARRAYAGG(rot.role_name) as roleNames,"
+        + " JSON_ARRAYAGG(rot.role_id) as roleIds"
+        + " FROM ("
+        + " SELECT ut.user_id FROM "
+        + USER_TABLE_NAME
+        + " ut JOIN "
+        + MetalakeMetaMapper.TABLE_NAME
+        + " mt ON ut.metalake_id = mt.metalake_id"
+        + " WHERE mt.metalake_name = #{metalakeName}"
+        + " AND ut.deleted_at = 0 AND mt.deleted_at = 0"
+        + " ORDER BY ut.user_id ASC LIMIT #{limit} OFFSET #{offset}"
+        + " ) paginated"
+        + " JOIN "
+        + USER_TABLE_NAME
+        + " ut ON ut.user_id = paginated.user_id"
+        + " LEFT OUTER JOIN ("
+        + " SELECT * FROM "
+        + USER_ROLE_RELATION_TABLE_NAME
+        + " WHERE deleted_at = 0)"
+        + " AS rt ON rt.user_id = ut.user_id"
+        + " LEFT OUTER JOIN ("
+        + " SELECT * FROM "
+        + ROLE_TABLE_NAME
+        + " WHERE deleted_at = 0)"
+        + " AS rot ON rot.role_id = rt.role_id"
+        + " GROUP BY ut.user_id"
+        + " ORDER BY ut.user_id ASC";
+  }
 }
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/GroupMetaPostgreSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/GroupMetaPostgreSQLProvider.java
index 4f617f98b5..450e26cef2 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/GroupMetaPostgreSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/GroupMetaPostgreSQLProvider.java
@@ -23,6 +23,7 @@ import static 
org.apache.gravitino.storage.relational.mapper.RoleMetaMapper.GROU
 import static 
org.apache.gravitino.storage.relational.mapper.RoleMetaMapper.ROLE_TABLE_NAME;
 
 import java.util.List;
+import org.apache.gravitino.storage.relational.mapper.MetalakeMetaMapper;
 import 
org.apache.gravitino.storage.relational.mapper.provider.base.GroupMetaBaseSQLProvider;
 import org.apache.gravitino.storage.relational.po.GroupPO;
 import org.apache.ibatis.annotations.Param;
@@ -99,6 +100,46 @@ public class GroupMetaPostgreSQLProvider extends 
GroupMetaBaseSQLProvider {
         + " GROUP BY gt.group_id";
   }
 
+  @Override
+  public String listExtendedGroupPOsByMetalakeNamePaginated(
+      @Param("metalakeName") String metalakeName,
+      @Param("offset") int offset,
+      @Param("limit") int limit) {
+    return "SELECT gt.group_id as groupId, gt.group_name as groupName,"
+        + " gt.metalake_id as metalakeId,"
+        + " gt.external_id as externalId,"
+        + " gt.audit_info as auditInfo,"
+        + " gt.current_version as currentVersion, gt.last_version as 
lastVersion,"
+        + " gt.deleted_at as deletedAt,"
+        + " JSON_AGG(rot.role_name) as roleNames,"
+        + " JSON_AGG(rot.role_id) as roleIds"
+        + " FROM ("
+        + " SELECT gt.group_id FROM "
+        + GROUP_TABLE_NAME
+        + " gt JOIN "
+        + MetalakeMetaMapper.TABLE_NAME
+        + " mt ON gt.metalake_id = mt.metalake_id"
+        + " WHERE mt.metalake_name = #{metalakeName}"
+        + " AND gt.deleted_at = 0 AND mt.deleted_at = 0"
+        + " ORDER BY gt.group_id ASC LIMIT #{limit} OFFSET #{offset}"
+        + " ) paginated"
+        + " JOIN "
+        + GROUP_TABLE_NAME
+        + " gt ON gt.group_id = paginated.group_id"
+        + " LEFT OUTER JOIN ("
+        + " SELECT * FROM "
+        + GROUP_ROLE_RELATION_TABLE_NAME
+        + " WHERE deleted_at = 0)"
+        + " AS rt ON rt.group_id = gt.group_id"
+        + " LEFT OUTER JOIN ("
+        + " SELECT * FROM "
+        + ROLE_TABLE_NAME
+        + " WHERE deleted_at = 0)"
+        + " AS rot ON rot.role_id = rt.role_id"
+        + " GROUP BY gt.group_id"
+        + " ORDER BY gt.group_id ASC";
+  }
+
   @Override
   public String listExtendedGroupPOsByMetalakeIdAndNames(
       @Param("metalakeId") Long metalakeId, @Param("groupNames") List<String> 
groupNames) {
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/UserMetaPostgreSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/UserMetaPostgreSQLProvider.java
index 2305535b1d..a454efa6c6 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/UserMetaPostgreSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/UserMetaPostgreSQLProvider.java
@@ -22,6 +22,7 @@ import static 
org.apache.gravitino.storage.relational.mapper.RoleMetaMapper.ROLE
 import static 
org.apache.gravitino.storage.relational.mapper.UserMetaMapper.USER_ROLE_RELATION_TABLE_NAME;
 import static 
org.apache.gravitino.storage.relational.mapper.UserRoleRelMapper.USER_TABLE_NAME;
 
+import org.apache.gravitino.storage.relational.mapper.MetalakeMetaMapper;
 import 
org.apache.gravitino.storage.relational.mapper.provider.base.UserMetaBaseSQLProvider;
 import org.apache.gravitino.storage.relational.po.UserPO;
 import org.apache.ibatis.annotations.Param;
@@ -100,6 +101,46 @@ public class UserMetaPostgreSQLProvider extends 
UserMetaBaseSQLProvider {
         + " GROUP BY ut.user_id";
   }
 
+  @Override
+  public String listExtendedUserPOsByMetalakeNamePaginated(
+      @Param("metalakeName") String metalakeName,
+      @Param("offset") int offset,
+      @Param("limit") int limit) {
+    return "SELECT ut.user_id as userId, ut.user_name as userName,"
+        + " ut.metalake_id as metalakeId,"
+        + " ut.external_id as externalId, ut.enabled as enabled,"
+        + " ut.audit_info as auditInfo,"
+        + " ut.current_version as currentVersion, ut.last_version as 
lastVersion,"
+        + " ut.deleted_at as deletedAt,"
+        + " JSON_AGG(rot.role_name) as roleNames,"
+        + " JSON_AGG(rot.role_id) as roleIds"
+        + " FROM ("
+        + " SELECT ut.user_id FROM "
+        + USER_TABLE_NAME
+        + " ut JOIN "
+        + MetalakeMetaMapper.TABLE_NAME
+        + " mt ON ut.metalake_id = mt.metalake_id"
+        + " WHERE mt.metalake_name = #{metalakeName}"
+        + " AND ut.deleted_at = 0 AND mt.deleted_at = 0"
+        + " ORDER BY ut.user_id ASC LIMIT #{limit} OFFSET #{offset}"
+        + " ) paginated"
+        + " JOIN "
+        + USER_TABLE_NAME
+        + " ut ON ut.user_id = paginated.user_id"
+        + " LEFT OUTER JOIN ("
+        + " SELECT * FROM "
+        + USER_ROLE_RELATION_TABLE_NAME
+        + " WHERE deleted_at = 0)"
+        + " AS rt ON rt.user_id = ut.user_id"
+        + " LEFT OUTER JOIN ("
+        + " SELECT * FROM "
+        + ROLE_TABLE_NAME
+        + " WHERE deleted_at = 0)"
+        + " AS rot ON rot.role_id = rt.role_id"
+        + " GROUP BY ut.user_id"
+        + " ORDER BY ut.user_id ASC";
+  }
+
   @Override
   public String deleteUserMetasByLegacyTimeline(
       @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit) 
{
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/GroupMetaService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/GroupMetaService.java
index 50b2003862..1229fc0c49 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/GroupMetaService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/GroupMetaService.java
@@ -36,6 +36,7 @@ import org.apache.gravitino.HasIdentifier;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.authorization.AuthorizationUtils;
+import org.apache.gravitino.authorization.PagedResult;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.meta.GroupEntity;
 import org.apache.gravitino.meta.RoleEntity;
@@ -484,4 +485,42 @@ public class GroupMetaService {
                         groupId, Entity.EntityType.GROUP.name())));
     return true;
   }
+
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "countGroupsByMetalake")
+  public long countGroupsByMetalake(String metalakeName) {
+    Long count =
+        SessionUtils.getWithoutCommit(
+            GroupMetaMapper.class, mapper -> 
mapper.countGroupMetasByMetalakeName(metalakeName));
+    return count == null ? 0L : count;
+  }
+
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "listGroupsByMetalakePaginated")
+  public PagedResult<GroupEntity> listGroupsByMetalakePaginated(
+      String metalakeName, int offset, int limit) {
+    Preconditions.checkArgument(offset >= 0, "offset must be >= 0");
+    Preconditions.checkArgument(limit >= 0, "limit must be >= 0");
+
+    long totalCount = countGroupsByMetalake(metalakeName);
+    if (limit == 0 || offset >= totalCount) {
+      return new PagedResult<>(totalCount, Collections.emptyList());
+    }
+
+    List<ExtendedGroupPO> groupPOs =
+        SessionUtils.getWithoutCommit(
+            GroupMetaMapper.class,
+            mapper ->
+                
mapper.listExtendedGroupPOsByMetalakeNamePaginated(metalakeName, offset, 
limit));
+    List<GroupEntity> groups =
+        groupPOs.stream()
+            .map(
+                po ->
+                    POConverters.fromExtendedGroupPO(
+                        po, AuthorizationUtils.ofGroupNamespace(metalakeName)))
+            .collect(Collectors.toList());
+    return new PagedResult<>(totalCount, groups);
+  }
 }
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/UserMetaService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/UserMetaService.java
index 0fbdab8ca2..3eacd9850f 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/UserMetaService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/UserMetaService.java
@@ -36,6 +36,7 @@ import org.apache.gravitino.HasIdentifier;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.authorization.AuthorizationUtils;
+import org.apache.gravitino.authorization.PagedResult;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.meta.RoleEntity;
 import org.apache.gravitino.meta.UserEntity;
@@ -472,4 +473,42 @@ public class UserMetaService {
                         userId, Entity.EntityType.USER.name())));
     return true;
   }
+
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "countUsersByMetalake")
+  public long countUsersByMetalake(String metalakeName) {
+    Long count =
+        SessionUtils.getWithoutCommit(
+            UserMetaMapper.class, mapper -> 
mapper.countUserMetasByMetalakeName(metalakeName));
+    return count == null ? 0L : count;
+  }
+
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "listUsersByMetalakePaginated")
+  public PagedResult<UserEntity> listUsersByMetalakePaginated(
+      String metalakeName, int offset, int limit) {
+    Preconditions.checkArgument(offset >= 0, "offset must be >= 0");
+    Preconditions.checkArgument(limit >= 0, "limit must be >= 0");
+
+    long totalCount = countUsersByMetalake(metalakeName);
+    if (limit == 0 || offset >= totalCount) {
+      return new PagedResult<>(totalCount, Collections.emptyList());
+    }
+
+    List<ExtendedUserPO> userPOs =
+        SessionUtils.getWithoutCommit(
+            UserMetaMapper.class,
+            mapper ->
+                
mapper.listExtendedUserPOsByMetalakeNamePaginated(metalakeName, offset, limit));
+    List<UserEntity> users =
+        userPOs.stream()
+            .map(
+                po ->
+                    POConverters.fromExtendedUserPO(
+                        po, AuthorizationUtils.ofUserNamespace(metalakeName)))
+            .collect(Collectors.toList());
+    return new PagedResult<>(totalCount, users);
+  }
 }
diff --git 
a/core/src/test/java/org/apache/gravitino/audit/v2/TestCompatibilityUtils.java 
b/core/src/test/java/org/apache/gravitino/audit/v2/TestCompatibilityUtils.java
index 9e00a0b04e..94840fed0f 100644
--- 
a/core/src/test/java/org/apache/gravitino/audit/v2/TestCompatibilityUtils.java
+++ 
b/core/src/test/java/org/apache/gravitino/audit/v2/TestCompatibilityUtils.java
@@ -134,6 +134,8 @@ public class TestCompatibilityUtils {
       {OperationType.ENABLE_USER, Operation.ENABLE_USER},
       {OperationType.DISABLE_USER, Operation.DISABLE_USER},
       {OperationType.LIST_USERS, Operation.LIST_USERS},
+      {OperationType.LIST_USERS_PAGED, Operation.LIST_USERS},
+      {OperationType.COUNT_USERS, Operation.LIST_USERS},
       {OperationType.LIST_USER_NAMES, Operation.LIST_USER_NAMES},
       {OperationType.GRANT_USER_ROLES, Operation.GRANT_USER_ROLES},
       {OperationType.REVOKE_USER_ROLES, Operation.REVOKE_USER_ROLES},
@@ -146,6 +148,8 @@ public class TestCompatibilityUtils {
       {OperationType.REMOVE_GROUP_BY_ID, Operation.REMOVE_GROUP_BY_ID},
       {OperationType.ALTER_GROUP, Operation.ALTER_GROUP},
       {OperationType.LIST_GROUPS, Operation.LIST_GROUPS},
+      {OperationType.LIST_GROUPS_PAGED, Operation.LIST_GROUPS},
+      {OperationType.COUNT_GROUPS, Operation.LIST_GROUPS},
       {OperationType.LIST_GROUP_NAMES, Operation.LIST_GROUP_NAMES},
       {OperationType.GRANT_GROUP_ROLES, Operation.GRANT_GROUP_ROLES},
       {OperationType.REVOKE_GROUP_ROLES, Operation.REVOKE_GROUP_ROLES},
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 182a184afd..fbc54b2903 100644
--- 
a/core/src/test/java/org/apache/gravitino/authorization/TestAccessControlManager.java
+++ 
b/core/src/test/java/org/apache/gravitino/authorization/TestAccessControlManager.java
@@ -70,6 +70,7 @@ import org.apache.gravitino.connector.BaseCatalog;
 import org.apache.gravitino.connector.authorization.AuthorizationPlugin;
 import org.apache.gravitino.exceptions.GroupAlreadyExistsException;
 import org.apache.gravitino.exceptions.NoSuchGroupException;
+import org.apache.gravitino.exceptions.NoSuchMetalakeException;
 import org.apache.gravitino.exceptions.NoSuchRoleException;
 import org.apache.gravitino.exceptions.NoSuchUserException;
 import org.apache.gravitino.exceptions.RoleAlreadyExistsException;
@@ -622,6 +623,67 @@ public class TestAccessControlManager {
     accessControlManager.removeGroup(METALAKE, group);
   }
 
+  @Test
+  public void testUserPagination() {
+    long beforeCount = accessControlManager.countUsers(METALAKE);
+    for (int i = 0; i < 5; i++) {
+      accessControlManager.addUser(METALAKE, "page_user_" + i);
+    }
+
+    Assertions.assertEquals(beforeCount + 5, 
accessControlManager.countUsers(METALAKE));
+
+    PagedResult<User> page = accessControlManager.listUsers(METALAKE, (int) 
beforeCount, 2);
+    Assertions.assertEquals(beforeCount + 5, page.totalCount());
+    Assertions.assertEquals(2, page.items().size());
+
+    // Repeated call with the same offset/limit must be stable.
+    PagedResult<User> pageAgain = accessControlManager.listUsers(METALAKE, 
(int) beforeCount, 2);
+    Assertions.assertEquals(page.items().get(0).name(), 
pageAgain.items().get(0).name());
+    Assertions.assertEquals(page.items().get(1).name(), 
pageAgain.items().get(1).name());
+
+    PagedResult<User> lastPage =
+        accessControlManager.listUsers(METALAKE, (int) beforeCount + 4, 10);
+    Assertions.assertEquals(beforeCount + 5, lastPage.totalCount());
+    Assertions.assertEquals(1, lastPage.items().size());
+
+    for (int i = 0; i < 5; i++) {
+      accessControlManager.removeUser(METALAKE, "page_user_" + i);
+    }
+
+    Assertions.assertThrows(
+        NoSuchMetalakeException.class, () -> 
accessControlManager.countUsers("no_such_metalake"));
+    Assertions.assertThrows(
+        NoSuchMetalakeException.class,
+        () -> accessControlManager.listUsers("no_such_metalake", 0, 10));
+  }
+
+  @Test
+  public void testGroupPagination() {
+    long beforeCount = accessControlManager.countGroups(METALAKE);
+    for (int i = 0; i < 3; i++) {
+      accessControlManager.addGroup(METALAKE, "page_group_" + i);
+    }
+    Assertions.assertEquals(beforeCount + 3, 
accessControlManager.countGroups(METALAKE));
+
+    PagedResult<Group> page = accessControlManager.listGroups(METALAKE, (int) 
beforeCount, 2);
+    Assertions.assertEquals(beforeCount + 3, page.totalCount());
+    Assertions.assertEquals(2, page.items().size());
+
+    PagedResult<Group> pageAgain = accessControlManager.listGroups(METALAKE, 
(int) beforeCount, 2);
+    Assertions.assertEquals(page.items().get(0).name(), 
pageAgain.items().get(0).name());
+    Assertions.assertEquals(page.items().get(1).name(), 
pageAgain.items().get(1).name());
+
+    for (int i = 0; i < 3; i++) {
+      accessControlManager.removeGroup(METALAKE, "page_group_" + i);
+    }
+
+    Assertions.assertThrows(
+        NoSuchMetalakeException.class, () -> 
accessControlManager.countGroups("no_such_metalake"));
+    Assertions.assertThrows(
+        NoSuchMetalakeException.class,
+        () -> accessControlManager.listGroups("no_such_metalake", 0, 10));
+  }
+
   private void createCatalogRole(String role) {
     accessControlManager.createRole(
         METALAKE,
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 ab4e0ff146..5bd7a70662 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
@@ -25,6 +25,7 @@ import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
 import com.google.common.collect.ImmutableList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
@@ -34,6 +35,7 @@ import 
org.apache.gravitino.authorization.AccessControlDispatcher;
 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.exceptions.GravitinoRuntimeException;
 import org.apache.gravitino.exceptions.NoSuchGroupException;
 import org.apache.gravitino.exceptions.NoSuchMetalakeException;
@@ -264,6 +266,90 @@ public class TestGroupEvent {
     Assertions.assertEquals(identifier, listGroupsFailureEvent.identifier());
   }
 
+  @Test
+  void testListGroupsPagedPreEvent() {
+    dispatcher.listGroups(METALAKE, 0, 10);
+
+    PreEvent preEvent = dummyEventListener.popPreEvent();
+    Assertions.assertEquals(ListGroupsPagedPreEvent.class, 
preEvent.getClass());
+    Assertions.assertEquals(OperationStatus.UNPROCESSED, 
preEvent.operationStatus());
+    Assertions.assertEquals(OperationType.LIST_GROUPS_PAGED, 
preEvent.operationType());
+
+    ListGroupsPagedPreEvent pagedPreEvent = (ListGroupsPagedPreEvent) preEvent;
+    Assertions.assertEquals(NameIdentifier.of(METALAKE), 
pagedPreEvent.identifier());
+    Assertions.assertEquals(0, pagedPreEvent.offset());
+    Assertions.assertEquals(10, pagedPreEvent.limit());
+  }
+
+  @Test
+  void testListGroupsPagedEvent() {
+    dispatcher.listGroups(METALAKE, 0, 10);
+
+    Event event = dummyEventListener.popPostEvent();
+    Assertions.assertEquals(ListGroupsPagedEvent.class, event.getClass());
+    Assertions.assertEquals(OperationStatus.SUCCESS, event.operationStatus());
+    Assertions.assertEquals(OperationType.LIST_GROUPS_PAGED, 
event.operationType());
+
+    ListGroupsPagedEvent pagedEvent = (ListGroupsPagedEvent) event;
+    Assertions.assertEquals(NameIdentifier.of(METALAKE), 
pagedEvent.identifier());
+    Assertions.assertEquals(0, pagedEvent.offset());
+    Assertions.assertEquals(10, pagedEvent.limit());
+    Assertions.assertEquals(2, pagedEvent.resultCount());
+    Assertions.assertEquals(2L, pagedEvent.totalCount());
+  }
+
+  @Test
+  void testListGroupsPagedFailureEvent() {
+    Assertions.assertThrows(
+        GravitinoRuntimeException.class, () -> 
failureDispatcher.listGroups(METALAKE, 1, 5));
+
+    Event event = dummyEventListener.popPostEvent();
+    Assertions.assertEquals(ListGroupsPagedFailureEvent.class, 
event.getClass());
+    Assertions.assertEquals(OperationStatus.FAILURE, event.operationStatus());
+    Assertions.assertEquals(OperationType.LIST_GROUPS_PAGED, 
event.operationType());
+
+    ListGroupsPagedFailureEvent failureEvent = (ListGroupsPagedFailureEvent) 
event;
+    Assertions.assertEquals(NameIdentifier.of(METALAKE), 
failureEvent.identifier());
+    Assertions.assertEquals(1, failureEvent.offset());
+    Assertions.assertEquals(5, failureEvent.limit());
+  }
+
+  @Test
+  void testCountGroupsPreEvent() {
+    dispatcher.countGroups(METALAKE);
+
+    PreEvent preEvent = dummyEventListener.popPreEvent();
+    Assertions.assertEquals(CountGroupsPreEvent.class, preEvent.getClass());
+    Assertions.assertEquals(OperationStatus.UNPROCESSED, 
preEvent.operationStatus());
+    Assertions.assertEquals(OperationType.COUNT_GROUPS, 
preEvent.operationType());
+    Assertions.assertEquals(NameIdentifier.of(METALAKE), 
preEvent.identifier());
+  }
+
+  @Test
+  void testCountGroupsEvent() {
+    dispatcher.countGroups(METALAKE);
+
+    Event event = dummyEventListener.popPostEvent();
+    Assertions.assertEquals(CountGroupsEvent.class, event.getClass());
+    Assertions.assertEquals(OperationStatus.SUCCESS, event.operationStatus());
+    Assertions.assertEquals(OperationType.COUNT_GROUPS, event.operationType());
+
+    CountGroupsEvent countEvent = (CountGroupsEvent) event;
+    Assertions.assertEquals(NameIdentifier.of(METALAKE), 
countEvent.identifier());
+    Assertions.assertEquals(2L, countEvent.count());
+  }
+
+  @Test
+  void testCountGroupsFailureEvent() {
+    Assertions.assertThrows(
+        GravitinoRuntimeException.class, () -> 
failureDispatcher.countGroups(METALAKE));
+
+    Event event = dummyEventListener.popPostEvent();
+    Assertions.assertEquals(CountGroupsFailureEvent.class, event.getClass());
+    Assertions.assertEquals(OperationStatus.FAILURE, event.operationStatus());
+    Assertions.assertEquals(OperationType.COUNT_GROUPS, event.operationType());
+  }
+
   @Test
   void testListGroupNamesPreEvent() {
     dispatcher.listGroupNames(METALAKE);
@@ -691,6 +777,9 @@ public class TestGroupEvent {
     when(dispatcher.removeGroupById(METALAKE, GROUP_ID)).thenReturn(true);
 
     when(dispatcher.listGroups(METALAKE)).thenReturn(new Group[] {group, 
otherGroup});
+    when(dispatcher.listGroups(eq(METALAKE), eq(0), eq(10)))
+        .thenReturn(new PagedResult<>(2, Arrays.asList(group, otherGroup)));
+    when(dispatcher.countGroups(METALAKE)).thenReturn(2L);
     when(dispatcher.listGroupNames(METALAKE)).thenReturn(new String[] 
{groupName, otherGroupName});
 
     when(dispatcher.getGroup(METALAKE, groupName)).thenReturn(group);
diff --git 
a/core/src/test/java/org/apache/gravitino/listener/api/event/TestUserEvent.java 
b/core/src/test/java/org/apache/gravitino/listener/api/event/TestUserEvent.java
index ef10a28cea..b38b0bc453 100644
--- 
a/core/src/test/java/org/apache/gravitino/listener/api/event/TestUserEvent.java
+++ 
b/core/src/test/java/org/apache/gravitino/listener/api/event/TestUserEvent.java
@@ -25,11 +25,13 @@ import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
 import com.google.common.collect.ImmutableList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.authorization.AuthorizationUtils;
+import org.apache.gravitino.authorization.PagedResult;
 import org.apache.gravitino.authorization.User;
 import org.apache.gravitino.authorization.UserChange;
 import org.apache.gravitino.exceptions.GravitinoRuntimeException;
@@ -267,6 +269,91 @@ public class TestUserEvent {
         NameIdentifier.of(INEXIST_METALAKE), 
listUsersFailureEvent.identifier());
   }
 
+  @Test
+  void testListUsersPagedPreEvent() {
+    dispatcher.listUsers(METALAKE, 0, 10);
+
+    PreEvent preEvent = dummyEventListener.popPreEvent();
+    Assertions.assertEquals(ListUsersPagedPreEvent.class, preEvent.getClass());
+    Assertions.assertEquals(OperationStatus.UNPROCESSED, 
preEvent.operationStatus());
+    Assertions.assertEquals(OperationType.LIST_USERS_PAGED, 
preEvent.operationType());
+
+    ListUsersPagedPreEvent pagedPreEvent = (ListUsersPagedPreEvent) preEvent;
+    Assertions.assertEquals(NameIdentifier.of(METALAKE), 
pagedPreEvent.identifier());
+    Assertions.assertEquals(0, pagedPreEvent.offset());
+    Assertions.assertEquals(10, pagedPreEvent.limit());
+  }
+
+  @Test
+  void testListUsersPagedEvent() {
+    dispatcher.listUsers(METALAKE, 0, 10);
+
+    Event event = dummyEventListener.popPostEvent();
+    Assertions.assertEquals(ListUsersPagedEvent.class, event.getClass());
+    Assertions.assertEquals(OperationStatus.SUCCESS, event.operationStatus());
+    Assertions.assertEquals(OperationType.LIST_USERS_PAGED, 
event.operationType());
+
+    ListUsersPagedEvent pagedEvent = (ListUsersPagedEvent) event;
+    Assertions.assertEquals(NameIdentifier.of(METALAKE), 
pagedEvent.identifier());
+    Assertions.assertEquals(0, pagedEvent.offset());
+    Assertions.assertEquals(10, pagedEvent.limit());
+    Assertions.assertEquals(2, pagedEvent.resultCount());
+    Assertions.assertEquals(2L, pagedEvent.totalCount());
+  }
+
+  @Test
+  void testListUsersPagedFailureEvent() {
+    Assertions.assertThrowsExactly(
+        GravitinoRuntimeException.class, () -> 
failureDispatcher.listUsers(METALAKE, 1, 5));
+
+    Event event = dummyEventListener.popPostEvent();
+    Assertions.assertEquals(ListUsersPagedFailureEvent.class, 
event.getClass());
+    Assertions.assertEquals(OperationStatus.FAILURE, event.operationStatus());
+    Assertions.assertEquals(OperationType.LIST_USERS_PAGED, 
event.operationType());
+
+    ListUsersPagedFailureEvent failureEvent = (ListUsersPagedFailureEvent) 
event;
+    Assertions.assertEquals(NameIdentifier.of(METALAKE), 
failureEvent.identifier());
+    Assertions.assertEquals(1, failureEvent.offset());
+    Assertions.assertEquals(5, failureEvent.limit());
+  }
+
+  @Test
+  void testCountUsersPreEvent() {
+    dispatcher.countUsers(METALAKE);
+
+    PreEvent preEvent = dummyEventListener.popPreEvent();
+    Assertions.assertEquals(CountUsersPreEvent.class, preEvent.getClass());
+    Assertions.assertEquals(OperationStatus.UNPROCESSED, 
preEvent.operationStatus());
+    Assertions.assertEquals(OperationType.COUNT_USERS, 
preEvent.operationType());
+    Assertions.assertEquals(NameIdentifier.of(METALAKE), 
preEvent.identifier());
+  }
+
+  @Test
+  void testCountUsersEvent() {
+    dispatcher.countUsers(METALAKE);
+
+    Event event = dummyEventListener.popPostEvent();
+    Assertions.assertEquals(CountUsersEvent.class, event.getClass());
+    Assertions.assertEquals(OperationStatus.SUCCESS, event.operationStatus());
+    Assertions.assertEquals(OperationType.COUNT_USERS, event.operationType());
+
+    CountUsersEvent countEvent = (CountUsersEvent) event;
+    Assertions.assertEquals(NameIdentifier.of(METALAKE), 
countEvent.identifier());
+    Assertions.assertEquals(2L, countEvent.count());
+  }
+
+  @Test
+  void testCountUsersFailureEvent() {
+    Assertions.assertThrowsExactly(
+        GravitinoRuntimeException.class, () -> 
failureDispatcher.countUsers(INEXIST_METALAKE));
+
+    Event event = dummyEventListener.popPostEvent();
+    Assertions.assertEquals(CountUsersFailureEvent.class, event.getClass());
+    Assertions.assertEquals(OperationStatus.FAILURE, event.operationStatus());
+    Assertions.assertEquals(OperationType.COUNT_USERS, event.operationType());
+    Assertions.assertEquals(NameIdentifier.of(INEXIST_METALAKE), 
event.identifier());
+  }
+
   @Test
   void testListUserNamesPreEvent() {
     dispatcher.listUserNames(METALAKE);
@@ -628,6 +715,9 @@ public class TestUserEvent {
     when(dispatcher.removeUserByExternalId(METALAKE, 
USER_EXT_ID)).thenReturn(true);
 
     when(dispatcher.listUsers(METALAKE)).thenReturn(new User[] {user, 
otherUser});
+    when(dispatcher.listUsers(eq(METALAKE), eq(0), eq(10)))
+        .thenReturn(new PagedResult<>(2, Arrays.asList(user, otherUser)));
+    when(dispatcher.countUsers(METALAKE)).thenReturn(2L);
     when(dispatcher.listUserNames(METALAKE)).thenReturn(new String[] 
{userName, otherUserName});
 
     when(dispatcher.getUser(METALAKE, userName)).thenReturn(user);
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/h2/TestGroupMetaH2Provider.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/h2/TestGroupMetaH2Provider.java
index dba49e9372..06da33b3e6 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/h2/TestGroupMetaH2Provider.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/h2/TestGroupMetaH2Provider.java
@@ -103,7 +103,7 @@ class TestGroupMetaH2Provider {
     try (ResultSet rs = statement.executeQuery(sql)) {
       rs.next();
       assertEquals("[\"role1\",\"role2\"]", 
rs.getString(COLUMN_LABEL_ROLE_NAMES));
-      assertEquals("[\"1\",\"2\"]", rs.getString(COLUMN_LABEL_ROLE_IDS));
+      assertEquals("[1,2]", rs.getString(COLUMN_LABEL_ROLE_IDS));
     }
   }
 
@@ -125,8 +125,9 @@ class TestGroupMetaH2Provider {
             .replace(QUERY_PARAM_METALAKE_ID, "3");
     try (ResultSet rs = statement.executeQuery(sql)) {
       rs.next();
-      assertEquals("[\"role3\"]", rs.getString(COLUMN_LABEL_ROLE_NAMES));
-      assertEquals("[\"3\",\"4\",\"5\"]", rs.getString(COLUMN_LABEL_ROLE_IDS));
+      // JSON_ARRAYAGG keeps empty role names and omits SQL NULLs; numeric 
role ids are unquoted.
+      assertEquals("[\"role3\",\"\"]", rs.getString(COLUMN_LABEL_ROLE_NAMES));
+      assertEquals("[3,4,5]", rs.getString(COLUMN_LABEL_ROLE_IDS));
     }
   }
 
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestGroupMetaService.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestGroupMetaService.java
index 801e8dadb5..e14f91a4c3 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestGroupMetaService.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestGroupMetaService.java
@@ -36,11 +36,13 @@ import java.util.Comparator;
 import java.util.List;
 import java.util.Optional;
 import java.util.function.Function;
+import java.util.stream.Collectors;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.EntityAlreadyExistsException;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.authorization.AuthorizationUtils;
+import org.apache.gravitino.authorization.PagedResult;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.BaseMetalake;
@@ -1100,6 +1102,135 @@ class TestGroupMetaService extends TestJDBCBackend {
     assertThrowsExt(NoSuchEntityException.class, () -> 
svc.deleteGroup(group.nameIdentifier()));
   }
 
+  @TestTemplate
+  void testGroupPagination() throws IOException {
+    AuditInfo auditInfo =
+        
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build();
+    createAndInsertMakeLake(metalakeName);
+    createAndInsertCatalog(metalakeName, catalogName);
+
+    GroupMetaService svc = GroupMetaService.getInstance();
+    RoleMetaService roleMetaService = RoleMetaService.getInstance();
+    RoleEntity role1 =
+        createRoleEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            AuthorizationUtils.ofRoleNamespace(metalakeName),
+            "page_group_role",
+            auditInfo,
+            catalogName);
+    roleMetaService.insertRole(role1, false);
+
+    GroupEntity g1 =
+        createGroupEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            AuthorizationUtils.ofGroupNamespace(metalakeName),
+            "page_g1",
+            auditInfo);
+    GroupEntity g2 =
+        createGroupEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            AuthorizationUtils.ofGroupNamespace(metalakeName),
+            "page_g2",
+            auditInfo,
+            Lists.newArrayList(role1.name()),
+            Lists.newArrayList(role1.id()));
+    GroupEntity g3 =
+        createGroupEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            AuthorizationUtils.ofGroupNamespace(metalakeName),
+            "page_g3",
+            auditInfo);
+    GroupEntity g4 =
+        createGroupEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            AuthorizationUtils.ofGroupNamespace(metalakeName),
+            "page_g4",
+            auditInfo);
+    svc.insertGroup(g1, false);
+    svc.insertGroup(g2, false);
+    svc.insertGroup(g3, false);
+    svc.insertGroup(g4, false);
+
+    List<GroupEntity> ordered =
+        Lists.newArrayList(g1, g2, g3, g4).stream()
+            .sorted(Comparator.comparing(GroupEntity::id))
+            .collect(Collectors.toList());
+
+    Assertions.assertEquals(4, svc.countGroupsByMetalake(metalakeName));
+
+    // offset=1, limit=2 exercises JDBC OFFSET and stable ORDER BY group_id 
ASC.
+    PagedResult<GroupEntity> page = 
svc.listGroupsByMetalakePaginated(metalakeName, 1, 2);
+    Assertions.assertEquals(4, page.totalCount());
+    Assertions.assertEquals(2, page.items().size());
+    Assertions.assertEquals(ordered.get(1).name(), page.items().get(0).name());
+    Assertions.assertEquals(ordered.get(2).name(), page.items().get(1).name());
+    Assertions.assertEquals(ordered.get(1).id(), page.items().get(0).id());
+    Assertions.assertEquals(ordered.get(2).id(), page.items().get(1).id());
+
+    GroupEntity withRole =
+        ordered.stream().filter(g -> 
"page_g2".equals(g.name())).findFirst().orElseThrow();
+    int roleGroupOffset = ordered.indexOf(withRole);
+    PagedResult<GroupEntity> rolePage =
+        svc.listGroupsByMetalakePaginated(metalakeName, roleGroupOffset, 1);
+    Assertions.assertEquals(1, rolePage.items().size());
+    Assertions.assertEquals(
+        Sets.newHashSet("page_group_role"), 
Sets.newHashSet(rolePage.items().get(0).roleNames()));
+
+    PagedResult<GroupEntity> pageAgain = 
svc.listGroupsByMetalakePaginated(metalakeName, 1, 2);
+    Assertions.assertEquals(page.items().get(0).name(), 
pageAgain.items().get(0).name());
+    Assertions.assertEquals(page.items().get(1).name(), 
pageAgain.items().get(1).name());
+
+    Assertions.assertTrue(svc.listGroupsByMetalakePaginated(metalakeName, 0, 
0).items().isEmpty());
+    Assertions.assertTrue(
+        svc.listGroupsByMetalakePaginated(metalakeName, 10, 
10).items().isEmpty());
+  }
+
+  @TestTemplate
+  void testGroupPaginationWithSpecialRoleNames() throws IOException {
+    AuditInfo auditInfo =
+        
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build();
+    createAndInsertMakeLake(metalakeName);
+    createAndInsertCatalog(metalakeName, catalogName);
+
+    GroupMetaService svc = GroupMetaService.getInstance();
+    RoleMetaService roleMetaService = RoleMetaService.getInstance();
+    String quotedRole = "role\"quoted";
+    String backslashRole = "back\\slash";
+    RoleEntity roleQuoted =
+        createRoleEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            AuthorizationUtils.ofRoleNamespace(metalakeName),
+            quotedRole,
+            auditInfo,
+            catalogName);
+    RoleEntity roleBackslash =
+        createRoleEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            AuthorizationUtils.ofRoleNamespace(metalakeName),
+            backslashRole,
+            auditInfo,
+            catalogName);
+    roleMetaService.insertRole(roleQuoted, false);
+    roleMetaService.insertRole(roleBackslash, false);
+
+    GroupEntity group =
+        createGroupEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            AuthorizationUtils.ofGroupNamespace(metalakeName),
+            "special_role_group",
+            auditInfo,
+            Lists.newArrayList(quotedRole, backslashRole),
+            Lists.newArrayList(roleQuoted.id(), roleBackslash.id()));
+    svc.insertGroup(group, false);
+
+    PagedResult<GroupEntity> page = 
svc.listGroupsByMetalakePaginated(metalakeName, 0, 10);
+    Assertions.assertEquals(1, page.totalCount());
+    Assertions.assertEquals(1, page.items().size());
+    Assertions.assertEquals(
+        Sets.newHashSet(quotedRole, backslashRole),
+        Sets.newHashSet(page.items().get(0).roleNames()));
+  }
+
   private NameIdentifier groupExtIdent(String externalId) {
     return AuthorizationUtils.ofGroupExternalId(metalakeName, externalId);
   }
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestUserMetaService.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestUserMetaService.java
index f6370317a9..bb377e7c38 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestUserMetaService.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestUserMetaService.java
@@ -43,6 +43,7 @@ import org.apache.gravitino.EntityAlreadyExistsException;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.authorization.AuthorizationUtils;
+import org.apache.gravitino.authorization.PagedResult;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.BaseMetalake;
@@ -1262,6 +1263,141 @@ class TestUserMetaService extends TestJDBCBackend {
     assertTrue(none.isEmpty());
   }
 
+  @TestTemplate
+  void testUserPagination() throws IOException {
+    AuditInfo auditInfo =
+        
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build();
+    createAndInsertMakeLake(metalakeName);
+    CatalogEntity catalog =
+        createCatalog(
+            RandomIdGenerator.INSTANCE.nextId(), Namespace.of(metalakeName), 
"catalog", auditInfo);
+    backend.insert(catalog, false);
+
+    UserMetaService svc = UserMetaService.getInstance();
+    RoleMetaService roleMetaService = RoleMetaService.getInstance();
+    RoleEntity role1 =
+        createRoleEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            AuthorizationUtils.ofRoleNamespace(metalakeName),
+            "page_role",
+            auditInfo,
+            "catalog");
+    roleMetaService.insertRole(role1, false);
+
+    UserEntity u1 =
+        createUserEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            AuthorizationUtils.ofUserNamespace(metalakeName),
+            "page_u1",
+            auditInfo);
+    UserEntity u2 =
+        createUserEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            AuthorizationUtils.ofUserNamespace(metalakeName),
+            "page_u2",
+            auditInfo,
+            Lists.newArrayList(role1.name()),
+            Lists.newArrayList(role1.id()));
+    UserEntity u3 =
+        createUserEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            AuthorizationUtils.ofUserNamespace(metalakeName),
+            "page_u3",
+            auditInfo);
+    UserEntity u4 =
+        createUserEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            AuthorizationUtils.ofUserNamespace(metalakeName),
+            "page_u4",
+            auditInfo);
+    svc.insertUser(u1, false);
+    svc.insertUser(u2, false);
+    svc.insertUser(u3, false);
+    svc.insertUser(u4, false);
+
+    List<UserEntity> ordered =
+        Lists.newArrayList(u1, u2, u3, u4).stream()
+            .sorted(Comparator.comparing(UserEntity::id))
+            .collect(Collectors.toList());
+
+    Assertions.assertEquals(4, svc.countUsersByMetalake(metalakeName));
+
+    // offset=1, limit=2 exercises JDBC OFFSET and stable ORDER BY user_id ASC.
+    PagedResult<UserEntity> page = 
svc.listUsersByMetalakePaginated(metalakeName, 1, 2);
+    Assertions.assertEquals(4, page.totalCount());
+    Assertions.assertEquals(2, page.items().size());
+    Assertions.assertEquals(ordered.get(1).name(), page.items().get(0).name());
+    Assertions.assertEquals(ordered.get(2).name(), page.items().get(1).name());
+    Assertions.assertEquals(ordered.get(1).id(), page.items().get(0).id());
+    Assertions.assertEquals(ordered.get(2).id(), page.items().get(1).id());
+
+    UserEntity withRole =
+        ordered.stream().filter(u -> 
"page_u2".equals(u.name())).findFirst().orElseThrow();
+    int roleUserOffset = ordered.indexOf(withRole);
+    PagedResult<UserEntity> rolePage =
+        svc.listUsersByMetalakePaginated(metalakeName, roleUserOffset, 1);
+    Assertions.assertEquals(1, rolePage.items().size());
+    Assertions.assertEquals(
+        Sets.newHashSet("page_role"), 
Sets.newHashSet(rolePage.items().get(0).roleNames()));
+
+    PagedResult<UserEntity> pageAgain = 
svc.listUsersByMetalakePaginated(metalakeName, 1, 2);
+    Assertions.assertEquals(
+        
page.items().stream().map(UserEntity::name).collect(Collectors.toList()),
+        
pageAgain.items().stream().map(UserEntity::name).collect(Collectors.toList()));
+
+    Assertions.assertTrue(svc.listUsersByMetalakePaginated(metalakeName, 0, 
0).items().isEmpty());
+    Assertions.assertTrue(svc.listUsersByMetalakePaginated(metalakeName, 10, 
10).items().isEmpty());
+  }
+
+  @TestTemplate
+  void testUserPaginationWithSpecialRoleNames() throws IOException {
+    AuditInfo auditInfo =
+        
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build();
+    createAndInsertMakeLake(metalakeName);
+    CatalogEntity catalog =
+        createCatalog(
+            RandomIdGenerator.INSTANCE.nextId(), Namespace.of(metalakeName), 
"catalog", auditInfo);
+    backend.insert(catalog, false);
+
+    UserMetaService svc = UserMetaService.getInstance();
+    RoleMetaService roleMetaService = RoleMetaService.getInstance();
+    String quotedRole = "role\"quoted";
+    String backslashRole = "back\\slash";
+    RoleEntity roleQuoted =
+        createRoleEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            AuthorizationUtils.ofRoleNamespace(metalakeName),
+            quotedRole,
+            auditInfo,
+            "catalog");
+    RoleEntity roleBackslash =
+        createRoleEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            AuthorizationUtils.ofRoleNamespace(metalakeName),
+            backslashRole,
+            auditInfo,
+            "catalog");
+    roleMetaService.insertRole(roleQuoted, false);
+    roleMetaService.insertRole(roleBackslash, false);
+
+    UserEntity user =
+        createUserEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            AuthorizationUtils.ofUserNamespace(metalakeName),
+            "special_role_user",
+            auditInfo,
+            Lists.newArrayList(quotedRole, backslashRole),
+            Lists.newArrayList(roleQuoted.id(), roleBackslash.id()));
+    svc.insertUser(user, false);
+
+    PagedResult<UserEntity> page = 
svc.listUsersByMetalakePaginated(metalakeName, 0, 10);
+    Assertions.assertEquals(1, page.totalCount());
+    Assertions.assertEquals(1, page.items().size());
+    Assertions.assertEquals(
+        Sets.newHashSet(quotedRole, backslashRole),
+        Sets.newHashSet(page.items().get(0).roleNames()));
+  }
+
   @TestTemplate
   void testUserExtId() throws IOException {
     UserMetaService svc = userMetaService();

Reply via email to