This is an automated email from the ASF dual-hosted git repository.

yuqi1129 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 9d6f263354 [#11088] feat(authz): Optimize JCasbin cache consistency 
and role checks (#11174)
9d6f263354 is described below

commit 9d6f263354a53cedc749c805aa4b0a24a95babc9
Author: Qi Yu <[email protected]>
AuthorDate: Fri May 29 18:35:59 2026 +0800

    [#11088] feat(authz): Optimize JCasbin cache consistency and role checks 
(#11174)
    
    ### What changes were proposed in this pull request?
    
    This PR improves JCasbin authorization cache behavior and reduces DB
    access on hot authorization paths.
    
    Main changes:
    
    - Batch the current user, groups, user-role relations, group-role
    relations, and role version probes into one mapper query for
    `authorize()`.
    - Route `isSelf(ROLE)` through the same version-validated user/group
    role caches used by `authorize()`.
    - Add per-request cache priming in `AuthorizationRequestContext` to
    avoid repeated user/group/role probes within one request.
    - Add local cache invalidation hooks for user-role and group-role
    relation changes.
    - Add metadata-id / owner cache lookup helpers and poller-backed
    invalidation for multi-node deployments.
    - Add mapper/service tests and JCasbin authorizer tests for cache reuse,
    invalidation, and authorization behavior.
    
    ### Why are the changes needed?
    
    After disabling the previous global cache behavior for multi-node
    correctness, JCasbin authorization started doing extra DB probes on hot
    paths. This PR restores most of the lost performance by batching version
    checks and making `isSelf(ROLE)` reuse the same version-validated caches
    as normal authorization.
    
    It also makes local cache invalidation explicit when user-role or
    group-role relations change, which avoids stale role bindings after
    grant/revoke operations.
    
    Fix: #11088
    
    ### Does this PR introduce _any_ user-facing change?
    
    No.
    
    ### How was this patch tested?
    
    - `./gradlew :core:spotlessApply :server-common:spotlessApply`
    - `./gradlew :core:test --tests
    org.apache.gravitino.storage.relational.mapper.provider.base.TestAuthMappers
    -PskipITs`
    - `./gradlew :server-common:test --tests
    org.apache.gravitino.server.authorization.jcasbin.TestJcasbinAuthorizer
    -PskipITs`
    - `./gradlew :core:test --tests
    org.apache.gravitino.hook.TestAccessControlHookDispatcher -PskipITs`
    - `./gradlew :clients:client-java:test --tests
    
org.apache.gravitino.client.integration.test.authorization.FunctionAuthorizationIT
    -PskipDockerTests=false`
---
 .../authorization/AuthorizationRequestContext.java |  28 +++
 .../authorization/GravitinoAuthorizer.java         |  27 +-
 .../hook/AccessControlHookDispatcher.java          |  48 ++--
 .../storage/relational/mapper/OwnerMetaMapper.java |   8 +-
 .../mapper/OwnerMetaSQLProviderFactory.java        |  10 +-
 .../storage/relational/mapper/UserMetaMapper.java  |  34 +++
 .../mapper/UserMetaSQLProviderFactory.java         |   8 +
 .../provider/base/OwnerMetaBaseSQLProvider.java    |  77 ++++--
 .../provider/base/UserMetaBaseSQLProvider.java     | 117 +++++++++
 .../postgresql/OwnerMetaPostgreSQLProvider.java    |  41 +++-
 .../relational/po/auth/AuthPrefetchRow.java        | 227 +++++++++++++++++
 .../hook/TestAccessControlHookDispatcher.java      |  38 +++
 .../mapper/provider/base/TestAuthMappers.java      |  32 ++-
 .../relational/service/TestUserMetaService.java    |  98 ++++++++
 .../authorization/PassThroughAuthorizer.java       |   5 +-
 .../AuthorizationExpressionConverter.java          |   3 +-
 .../jcasbin/JcasbinAuthorizationLookups.java       |  42 +++-
 .../authorization/jcasbin/JcasbinAuthorizer.java   | 244 +++++++++++++++---
 .../authorization/jcasbin/JcasbinChangePoller.java |  61 +++--
 .../authorization/MockGravitinoAuthorizer.java     |   5 +-
 .../authorization/TestPassThroughAuthorizer.java   |   4 +-
 .../jcasbin/TestJcasbinAuthorizationLookups.java   |  85 ++++++-
 .../jcasbin/TestJcasbinAuthorizer.java             | 272 ++++++++++++++++++++-
 .../jcasbin/TestJcasbinChangePoller.java           |   2 +-
 .../filter/TestGravitinoInterceptionService.java   |   5 +-
 25 files changed, 1376 insertions(+), 145 deletions(-)

diff --git 
a/core/src/main/java/org/apache/gravitino/authorization/AuthorizationRequestContext.java
 
b/core/src/main/java/org/apache/gravitino/authorization/AuthorizationRequestContext.java
index 554fed8b52..d30f15bee1 100644
--- 
a/core/src/main/java/org/apache/gravitino/authorization/AuthorizationRequestContext.java
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/AuthorizationRequestContext.java
@@ -32,6 +32,7 @@ import lombok.Getter;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.storage.relational.po.auth.GroupUpdatedAt;
 import org.apache.gravitino.storage.relational.po.auth.OwnerInfo;
+import org.apache.gravitino.storage.relational.po.auth.RoleUpdatedAt;
 import org.apache.gravitino.storage.relational.po.auth.UserUpdatedAt;
 
 /**
@@ -74,6 +75,13 @@ public class AuthorizationRequestContext {
   /** Per-request metadataId→owner cache. Deduplicates isOwner within a single 
request. */
   private final Map<Long, Optional<OwnerInfo>> ownerCache = new 
ConcurrentHashMap<>();
 
+  /**
+   * Per-request roleId → {@link RoleUpdatedAt} map populated by the fat-JOIN 
prefetch on the
+   * authorize hot path. When present, {@code versionCheckAndLoadRoles} can 
skip its dedicated
+   * {@code batchGetRoleUpdatedAt} round trip.
+   */
+  private volatile Map<Long, RoleUpdatedAt> prefetchedRoleVersions;
+
   private volatile String originalAuthorizationExpression;
 
   /**
@@ -185,6 +193,26 @@ public class AuthorizationRequestContext {
     this.originalAuthorizationExpression = originalAuthorizationExpression;
   }
 
+  /**
+   * Returns the prefetched roleId → {@link RoleUpdatedAt} map, or {@code 
null} when the fat-JOIN
+   * prefetch has not run for this request.
+   *
+   * @return the prefetched role-versions map or {@code null}
+   */
+  public Map<Long, RoleUpdatedAt> getPrefetchedRoleVersions() {
+    return prefetchedRoleVersions;
+  }
+
+  /**
+   * Sets the prefetched roleId → {@link RoleUpdatedAt} map; called once per 
request by the
+   * authorize hot path after the fat-JOIN prefetch.
+   *
+   * @param prefetchedRoleVersions roleId → {@link RoleUpdatedAt} map
+   */
+  public void setPrefetchedRoleVersions(Map<Long, RoleUpdatedAt> 
prefetchedRoleVersions) {
+    this.prefetchedRoleVersions = prefetchedRoleVersions;
+  }
+
   /**
    * Composite key for {@link #allowAuthorizerCache} / {@link 
#denyAuthorizerCache}. Immutable —
    * mutating any field after construction would silently corrupt the {@link
diff --git 
a/core/src/main/java/org/apache/gravitino/authorization/GravitinoAuthorizer.java
 
b/core/src/main/java/org/apache/gravitino/authorization/GravitinoAuthorizer.java
index 12529f74c6..f072c15684 100644
--- 
a/core/src/main/java/org/apache/gravitino/authorization/GravitinoAuthorizer.java
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/GravitinoAuthorizer.java
@@ -88,9 +88,14 @@ public interface GravitinoAuthorizer extends Closeable {
    *
    * @param type user or group
    * @param nameIdentifier name of user or group
+   * @param requestContext authorization request context; enables per-request 
dedup with other
+   *     authorization calls in the same request
    * @return authorization result
    */
-  boolean isSelf(Entity.EntityType type, NameIdentifier nameIdentifier);
+  boolean isSelf(
+      Entity.EntityType type,
+      NameIdentifier nameIdentifier,
+      AuthorizationRequestContext requestContext);
 
   /**
    * Determine whether the user is the metalake user.
@@ -156,6 +161,26 @@ public interface GravitinoAuthorizer extends Closeable {
     }
   }
 
+  /**
+   * Called when the role assignments of a user change.
+   *
+   * @param metalake the metalake name
+   * @param userName the user name
+   */
+  default void handleUserRoleRelChange(String metalake, String userName) {
+    // default no-op for backward compatibility
+  }
+
+  /**
+   * Called when the role assignments of a group change.
+   *
+   * @param metalake the metalake name
+   * @param groupName the group name
+   */
+  default void handleGroupRoleRelChange(String metalake, String groupName) {
+    // default no-op for backward compatibility
+  }
+
   /**
    * This method is called to clear the owner relationship in jcasbin when the 
owner of the metadata
    * changes.
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 2692330962..a4b2e28007 100644
--- 
a/core/src/main/java/org/apache/gravitino/hook/AccessControlHookDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/hook/AccessControlHookDispatcher.java
@@ -121,7 +121,7 @@ public class AccessControlHookDispatcher implements 
AccessControlDispatcher {
   public User grantRolesToUser(String metalake, List<String> roles, String 
user)
       throws NoSuchUserException, IllegalRoleException, 
NoSuchMetalakeException {
     User grantedUser = dispatcher.grantRolesToUser(metalake, roles, user);
-    notifyRoleUserRelChange(metalake, roles);
+    notifyUserRoleBindingChange(metalake, roles, user);
     return grantedUser;
   }
 
@@ -129,7 +129,7 @@ public class AccessControlHookDispatcher implements 
AccessControlDispatcher {
   public Group grantRolesToGroup(String metalake, List<String> roles, String 
group)
       throws NoSuchGroupException, IllegalRoleException, 
NoSuchMetalakeException {
     Group grantedGroup = dispatcher.grantRolesToGroup(metalake, roles, group);
-    notifyRoleGroupRelChange(metalake, roles);
+    notifyGroupRoleBindingChange(metalake, roles, group);
     return grantedGroup;
   }
 
@@ -137,7 +137,7 @@ public class AccessControlHookDispatcher implements 
AccessControlDispatcher {
   public Group revokeRolesFromGroup(String metalake, List<String> roles, 
String group)
       throws NoSuchGroupException, IllegalRoleException, 
NoSuchMetalakeException {
     Group revokedGroup = dispatcher.revokeRolesFromGroup(metalake, roles, 
group);
-    notifyRoleGroupRelChange(metalake, roles);
+    notifyGroupRoleBindingChange(metalake, roles, group);
     return revokedGroup;
   }
 
@@ -145,7 +145,7 @@ public class AccessControlHookDispatcher implements 
AccessControlDispatcher {
   public User revokeRolesFromUser(String metalake, List<String> roles, String 
user)
       throws NoSuchUserException, IllegalRoleException, 
NoSuchMetalakeException {
     User revokedUser = dispatcher.revokeRolesFromUser(metalake, roles, user);
-    notifyRoleUserRelChange(metalake, roles);
+    notifyUserRoleBindingChange(metalake, roles, user);
     return revokedUser;
   }
 
@@ -236,35 +236,49 @@ public class AccessControlHookDispatcher implements 
AccessControlDispatcher {
     return overriddenRole;
   }
 
-  private static void notifyRoleUserRelChange(String metalake, List<String> 
roles) {
+  /**
+   * Invalidates both the role-side cache for each of {@code roles} and the 
user-side cache for
+   * {@code user}. Used by grant/revoke flows that change the user→roles 
binding.
+   */
+  private static void notifyUserRoleBindingChange(
+      String metalake, List<String> roles, String user) {
     GravitinoAuthorizer gravitinoAuthorizer = 
GravitinoEnv.getInstance().gravitinoAuthorizer();
-    if (gravitinoAuthorizer != null) {
-      for (String role : roles) {
-        gravitinoAuthorizer.handleRolePrivilegeChange(metalake, role);
-      }
+    if (gravitinoAuthorizer == null) {
+      return;
+    }
+    for (String role : roles) {
+      gravitinoAuthorizer.handleRolePrivilegeChange(metalake, role);
     }
+    gravitinoAuthorizer.handleUserRoleRelChange(metalake, user);
   }
 
-  private static void notifyRoleUserRelChange(String metalake, String role) {
+  /**
+   * Invalidates both the role-side cache for each of {@code roles} and the 
group-side cache for
+   * {@code group}. Used by grant/revoke flows that change the group→roles 
binding.
+   */
+  private static void notifyGroupRoleBindingChange(
+      String metalake, List<String> roles, String group) {
     GravitinoAuthorizer gravitinoAuthorizer = 
GravitinoEnv.getInstance().gravitinoAuthorizer();
-    if (gravitinoAuthorizer != null) {
+    if (gravitinoAuthorizer == null) {
+      return;
+    }
+    for (String role : roles) {
       gravitinoAuthorizer.handleRolePrivilegeChange(metalake, role);
     }
+    gravitinoAuthorizer.handleGroupRoleRelChange(metalake, group);
   }
 
-  private static void notifyRoleUserRelChange(Long role) {
+  private static void notifyRoleUserRelChange(String metalake, String role) {
     GravitinoAuthorizer gravitinoAuthorizer = 
GravitinoEnv.getInstance().gravitinoAuthorizer();
     if (gravitinoAuthorizer != null) {
-      gravitinoAuthorizer.handleRolePrivilegeChange(role);
+      gravitinoAuthorizer.handleRolePrivilegeChange(metalake, role);
     }
   }
 
-  private static void notifyRoleGroupRelChange(String metalake, List<String> 
roles) {
+  private static void notifyRoleUserRelChange(Long role) {
     GravitinoAuthorizer gravitinoAuthorizer = 
GravitinoEnv.getInstance().gravitinoAuthorizer();
     if (gravitinoAuthorizer != null) {
-      for (String role : roles) {
-        gravitinoAuthorizer.handleRolePrivilegeChange(metalake, role);
-      }
+      gravitinoAuthorizer.handleRolePrivilegeChange(role);
     }
   }
 }
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaMapper.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaMapper.java
index f10571b4fc..e6453f2b66 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaMapper.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaMapper.java
@@ -118,8 +118,10 @@ public interface OwnerMetaMapper {
       @Param("metadataObjectType") String metadataObjectType);
 
   @SelectProvider(type = OwnerMetaSQLProviderFactory.class, method = 
"selectChangedOwners")
-  List<ChangedOwnerInfo> selectChangedOwners(@Param("lastConsumedId") long 
lastConsumedId);
+  List<ChangedOwnerInfo> selectChangedOwners(
+      @Param("lastConsumedUpdatedAt") long lastConsumedUpdatedAt,
+      @Param("lastConsumedUpdatedAtId") long lastConsumedUpdatedAtId);
 
-  @SelectProvider(type = OwnerMetaSQLProviderFactory.class, method = 
"selectMaxChangeId")
-  Long selectMaxChangeId();
+  @SelectProvider(type = OwnerMetaSQLProviderFactory.class, method = 
"selectMaxChangedOwner")
+  ChangedOwnerInfo selectMaxChangedOwner();
 }
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaSQLProviderFactory.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaSQLProviderFactory.java
index aac9fa8028..f73f93726f 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaSQLProviderFactory.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaSQLProviderFactory.java
@@ -121,11 +121,13 @@ public class OwnerMetaSQLProviderFactory {
     return 
getProvider().selectOwnerByMetadataObjectIdAndType(metadataObjectId, 
metadataObjectType);
   }
 
-  public static String selectChangedOwners(@Param("lastConsumedId") long 
lastConsumedId) {
-    return getProvider().selectChangedOwners(lastConsumedId);
+  public static String selectChangedOwners(
+      @Param("lastConsumedUpdatedAt") long lastConsumedUpdatedAt,
+      @Param("lastConsumedUpdatedAtId") long lastConsumedUpdatedAtId) {
+    return getProvider().selectChangedOwners(lastConsumedUpdatedAt, 
lastConsumedUpdatedAtId);
   }
 
-  public static String selectMaxChangeId() {
-    return getProvider().selectMaxChangeId();
+  public static String selectMaxChangedOwner() {
+    return getProvider().selectMaxChangedOwner();
   }
 }
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 498a90020f..4f16086b84 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
@@ -22,6 +22,7 @@ package org.apache.gravitino.storage.relational.mapper;
 import java.util.List;
 import org.apache.gravitino.storage.relational.po.ExtendedUserPO;
 import org.apache.gravitino.storage.relational.po.UserPO;
+import org.apache.gravitino.storage.relational.po.auth.AuthPrefetchRow;
 import org.apache.gravitino.storage.relational.po.auth.UserUpdatedAt;
 import org.apache.ibatis.annotations.DeleteProvider;
 import org.apache.ibatis.annotations.InsertProvider;
@@ -96,4 +97,37 @@ public interface UserMetaMapper {
   @SelectProvider(type = UserMetaSQLProviderFactory.class, method = 
"getUserUpdatedAt")
   UserUpdatedAt getUserUpdatedAt(
       @Param("metalakeName") String metalakeName, @Param("userName") String 
userName);
+
+  /**
+   * Single-round-trip auth prefetch for the JCasbin authorize hot path. 
Returns every version
+   * sentinel the request needs:
+   *
+   * <ul>
+   *   <li>the request user's {@code user_meta} row,
+   *   <li>each requested group's {@code group_meta} row,
+   *   <li>the user's direct {@code user_role_rel} role ids (joined to {@code 
role_meta} for their
+   *       {@code updated_at}),
+   *   <li>the group-inherited {@code group_role_rel} role ids (also joined 
for versions).
+   * </ul>
+   *
+   * <p>Implementation is up to four {@code UNION ALL} branches that map 1:1 
to {@link
+   * AuthPrefetchRow.Kind} values — see {@link AuthPrefetchRow}'s class-level 
Javadoc for the row
+   * shape and how its fields are interpreted per Kind. Consumers switch on 
{@code subjectType} and
+   * bucket each row into the matching collection.
+   *
+   * <p>Empty {@code groupNames} skips the {@code GROUP} and {@code 
GROUP_ROLE} branches so the
+   * query degrades to a 2-branch UNION ({@code USER} + {@code USER_ROLE}) for 
users without group
+   * membership.
+   *
+   * @param metalakeName the metalake the user and groups belong to
+   * @param userName the user to probe
+   * @param groupNames the group names to probe; may be empty (never null)
+   * @return polymorphic rows for the user, each present group, the user's 
direct roles, and each
+   *     group's inherited roles
+   */
+  @SelectProvider(type = UserMetaSQLProviderFactory.class, method = 
"batchGetAuthSubjectsForUser")
+  List<AuthPrefetchRow> batchGetAuthSubjectsForUser(
+      @Param("metalakeName") String metalakeName,
+      @Param("userName") String userName,
+      @Param("groupNames") List<String> groupNames);
 }
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 0c164899dd..27953c35de 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
@@ -20,6 +20,7 @@
 package org.apache.gravitino.storage.relational.mapper;
 
 import com.google.common.collect.ImmutableMap;
+import java.util.List;
 import java.util.Map;
 import org.apache.gravitino.storage.relational.JDBCBackend.JDBCBackendType;
 import 
org.apache.gravitino.storage.relational.mapper.provider.base.UserMetaBaseSQLProvider;
@@ -106,4 +107,11 @@ public class UserMetaSQLProviderFactory {
       @Param("metalakeName") String metalakeName, @Param("userName") String 
userName) {
     return getProvider().getUserUpdatedAt(metalakeName, userName);
   }
+
+  public static String batchGetAuthSubjectsForUser(
+      @Param("metalakeName") String metalakeName,
+      @Param("userName") String userName,
+      @Param("groupNames") List<String> groupNames) {
+    return getProvider().batchGetAuthSubjectsForUser(metalakeName, userName, 
groupNames);
+  }
 }
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/OwnerMetaBaseSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/OwnerMetaBaseSQLProvider.java
index 5c6d85296d..a89c3a1cc3 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/OwnerMetaBaseSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/OwnerMetaBaseSQLProvider.java
@@ -36,6 +36,11 @@ import org.apache.gravitino.storage.relational.po.OwnerRelPO;
 import org.apache.ibatis.annotations.Param;
 
 public class OwnerMetaBaseSQLProvider {
+  protected String currentTimestampMillisExpression() {
+    return "(UNIX_TIMESTAMP() * 1000.0)"
+        + " + EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(3)) / 1000";
+  }
+
   public String selectUserOwnerMetaByMetadataObjectIdAndType(
       @Param("metadataObjectId") Long metadataObjectId,
       @Param("metadataObjectType") String metadataObjectType) {
@@ -141,11 +146,14 @@ public class OwnerMetaBaseSQLProvider {
 
   public String batchSoftDeleteOwnerRelByMetadataObjects(
       @Param("deletions") List<OwnerRelForDeletion> deletions) {
+    String now = currentTimestampMillisExpression();
     return "<script>"
         + "UPDATE "
         + OWNER_TABLE_NAME
-        + " SET deleted_at = (UNIX_TIMESTAMP() * 1000.0)"
-        + " + EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(3)) / 1000"
+        + " SET deleted_at = "
+        + now
+        + ", updated_at = "
+        + now
         + " WHERE deleted_at = 0 AND ("
         + "<foreach collection='deletions' item='t' separator=' OR '>"
         + "(metadata_object_id = #{t.metadataObjectId} AND 
metadata_object_type = #{t.metadataObjectType})"
@@ -157,35 +165,47 @@ public class OwnerMetaBaseSQLProvider {
   public String softDeleteOwnerRelByMetadataObjectIdAndType(
       @Param("metadataObjectId") Long metadataObjectId,
       @Param("metadataObjectType") String metadataObjectType) {
+    String now = currentTimestampMillisExpression();
     return "UPDATE "
         + OWNER_TABLE_NAME
-        + " SET deleted_at = (UNIX_TIMESTAMP() * 1000.0)"
-        + " + EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(3)) / 1000"
+        + " SET deleted_at = "
+        + now
+        + ", updated_at = "
+        + now
         + " WHERE metadata_object_id = #{metadataObjectId} AND 
metadata_object_type = #{metadataObjectType} AND deleted_at = 0";
   }
 
   public String softDeleteOwnerRelByOwnerIdAndType(
       @Param("ownerId") Long ownerId, @Param("ownerType") String ownerType) {
+    String now = currentTimestampMillisExpression();
     return "UPDATE "
         + OWNER_TABLE_NAME
-        + " SET deleted_at = (UNIX_TIMESTAMP() * 1000.0)"
-        + " + EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(3)) / 1000"
+        + " SET deleted_at = "
+        + now
+        + ", updated_at = "
+        + now
         + " WHERE owner_id = #{ownerId} AND owner_type = #{ownerType} AND 
deleted_at = 0";
   }
 
   public String softDeleteOwnerRelByMetalakeId(@Param("metalakeId") Long 
metalakeId) {
+    String now = currentTimestampMillisExpression();
     return "UPDATE "
         + OWNER_TABLE_NAME
-        + " SET deleted_at = (UNIX_TIMESTAMP() * 1000.0)"
-        + " + EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(3)) / 1000"
+        + " SET deleted_at = "
+        + now
+        + ", updated_at = "
+        + now
         + " WHERE metalake_id = #{metalakeId} AND deleted_at = 0";
   }
 
   public String softDeleteOwnerRelByCatalogId(@Param("catalogId") Long 
catalogId) {
+    String now = currentTimestampMillisExpression();
     return "UPDATE "
         + OWNER_TABLE_NAME
-        + " ot SET ot.deleted_at = (UNIX_TIMESTAMP() * 1000.0)"
-        + " + EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(3)) / 1000"
+        + " ot SET ot.deleted_at = "
+        + now
+        + ", ot.updated_at = "
+        + now
         + " WHERE ot.deleted_at = 0 AND EXISTS ("
         + " SELECT ct.catalog_id FROM "
         + CatalogMetaMapper.TABLE_NAME
@@ -230,11 +250,14 @@ public class OwnerMetaBaseSQLProvider {
   }
 
   public String softDeleteOwnerRelBySchemaIds(@Param("schemaIds") List<Long> 
schemaIds) {
+    String now = currentTimestampMillisExpression();
     return "<script>"
         + "UPDATE "
         + OWNER_TABLE_NAME
-        + " ot SET ot.deleted_at = (UNIX_TIMESTAMP() * 1000.0)"
-        + " + EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(3)) / 1000"
+        + " ot SET ot.deleted_at = "
+        + now
+        + ", ot.updated_at = "
+        + now
         + " WHERE ot.deleted_at = 0 AND EXISTS ("
         + " SELECT st.schema_id FROM "
         + SchemaMetaMapper.TABLE_NAME
@@ -316,23 +339,33 @@ public class OwnerMetaBaseSQLProvider {
         + " ORDER BY updated_at DESC, id DESC LIMIT 1";
   }
 
-  public String selectChangedOwners(@Param("lastConsumedId") long 
lastConsumedId) {
-    // Owner changes are broadcast to every server instance because owner 
caches are local. Each
-    // instance tracks its own last consumed id; re-reading a row is harmless 
because cache
-    // invalidation is idempotent.
+  public String selectChangedOwners(
+      @Param("lastConsumedUpdatedAt") long lastConsumedUpdatedAt,
+      @Param("lastConsumedUpdatedAtId") long lastConsumedUpdatedAtId) {
+    // Owner changes are broadcast to every server instance because owner 
caches are local. Both
+    // inserts and soft-deletes advance owner_meta.updated_at, so a single 
(updated_at, id) keyset
+    // cursor catches every change; id is the tiebreaker when multiple rows 
share an updated_at
+    // millisecond (batch soft-deletes do that).
     return "SELECT id,"
         + " metadata_object_id as metadataObjectId,"
         + " metadata_object_type as metadataObjectType,"
         + " updated_at as updatedAt"
         + " FROM "
         + OWNER_TABLE_NAME
-        + " WHERE deleted_at = 0 AND id > #{lastConsumedId}"
-        + " ORDER BY id LIMIT 1000";
+        + " WHERE updated_at > #{lastConsumedUpdatedAt}"
+        + " OR (updated_at = #{lastConsumedUpdatedAt} AND id > 
#{lastConsumedUpdatedAtId})"
+        + " ORDER BY updated_at, id LIMIT 1000";
   }
 
-  public String selectMaxChangeId() {
-    // A newly started server has an empty local owner cache. It can start 
from the current max id
-    // and consume only owner changes that happen after startup.
-    return "SELECT COALESCE(MAX(id), 0) FROM " + OWNER_TABLE_NAME + " WHERE 
deleted_at = 0";
+  public String selectMaxChangedOwner() {
+    // A newly started server has an empty local owner cache. It can start 
from the current tail
+    // tuple and consume only owner changes that happen after startup.
+    return "SELECT id,"
+        + " metadata_object_id as metadataObjectId,"
+        + " metadata_object_type as metadataObjectType,"
+        + " updated_at as updatedAt"
+        + " FROM "
+        + OWNER_TABLE_NAME
+        + " ORDER BY updated_at DESC, id DESC LIMIT 1";
   }
 }
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 2842edd332..4bb92e103c 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
@@ -19,10 +19,13 @@
 
 package org.apache.gravitino.storage.relational.mapper.provider.base;
 
+import static 
org.apache.gravitino.storage.relational.mapper.GroupMetaMapper.GROUP_ROLE_RELATION_TABLE_NAME;
+import static 
org.apache.gravitino.storage.relational.mapper.GroupMetaMapper.GROUP_TABLE_NAME;
 import static 
org.apache.gravitino.storage.relational.mapper.RoleMetaMapper.ROLE_TABLE_NAME;
 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 java.util.List;
 import org.apache.gravitino.storage.relational.mapper.MetalakeMetaMapper;
 import org.apache.gravitino.storage.relational.po.UserPO;
 import org.apache.ibatis.annotations.Param;
@@ -208,4 +211,118 @@ public class UserMetaBaseSQLProvider {
         + " WHERE mm.metalake_name = #{metalakeName} AND um.user_name = 
#{userName}"
         + " AND um.deleted_at = 0";
   }
+
+  /**
+   * Builds the single-round-trip auth prefetch query for {@code (metalake, 
userName, groupNames)}.
+   *
+   * <p>The SQL is up to four {@code UNION ALL} branches that map 1:1 to the 
four {@code
+   * org.apache.gravitino.storage.relational.po.auth.AuthPrefetchRow.Kind} 
values:
+   *
+   * <ul>
+   *   <li>{@code 'USER'} branch — the request user's {@code user_meta} row.
+   *   <li>{@code 'USER_ROLE'} branch — roles bound directly to the request 
user via {@code
+   *       user_role_rel}; {@code parentId} carries the owning {@code user_id}.
+   *   <li>{@code 'GROUP'} branch — the request user's groups (one row per 
name in {@code
+   *       groupNames}).
+   *   <li>{@code 'GROUP_ROLE'} branch — roles inherited via group membership 
through {@code
+   *       group_role_rel}; {@code parentId} carries the owning {@code 
group_id}.
+   * </ul>
+   *
+   * <p>The {@code GROUP} / {@code GROUP_ROLE} branches are appended only when 
{@code groupNames} is
+   * non-empty, leaving a 2-branch query in the no-group case.
+   *
+   * <p>All branches must SELECT the same column list and aliases ({@code 
subjectType}, {@code
+   * entityId}, {@code entityName}, {@code updatedAt}, {@code bindingOwnerId}) 
because {@code UNION
+   * ALL} requires a uniform row shape. The aliases match {@code 
AuthPrefetchRow}'s field names so
+   * MyBatis can reflectively map each row.
+   *
+   * @param metalakeName the metalake the user belongs to
+   * @param userName the request user's name
+   * @param groupNames the user's group memberships; may be empty
+   * @return the SQL string
+   */
+  public String batchGetAuthSubjectsForUser(
+      @Param("metalakeName") String metalakeName,
+      @Param("userName") String userName,
+      @Param("groupNames") List<String> groupNames) {
+    // 'USER' branch → AuthPrefetchRow.Kind.USER.
+    String userBranch =
+        "SELECT 'USER' AS subjectType, um.user_id AS entityId, um.user_name AS 
entityName,"
+            + " um.updated_at AS updatedAt, NULL AS bindingOwnerId"
+            + " FROM "
+            + USER_TABLE_NAME
+            + " um"
+            + " JOIN "
+            + MetalakeMetaMapper.TABLE_NAME
+            + " mm ON um.metalake_id = mm.metalake_id AND mm.deleted_at = 0"
+            + " WHERE mm.metalake_name = #{metalakeName} AND um.user_name = 
#{userName}"
+            + " AND um.deleted_at = 0";
+
+    // 'USER_ROLE' branch → AuthPrefetchRow.Kind.USER_ROLE.
+    // bindingOwnerId carries the owning user_id so consumers can bucket roles 
back to that user.
+    String userRoleBranch =
+        " UNION ALL "
+            + "SELECT 'USER_ROLE' AS subjectType, rm.role_id AS entityId, 
rm.role_name AS entityName,"
+            + " rm.updated_at AS updatedAt, ur.user_id AS bindingOwnerId"
+            + " FROM "
+            + USER_TABLE_NAME
+            + " um"
+            + " JOIN "
+            + MetalakeMetaMapper.TABLE_NAME
+            + " mm3 ON um.metalake_id = mm3.metalake_id AND mm3.deleted_at = 0"
+            + " JOIN "
+            + USER_ROLE_RELATION_TABLE_NAME
+            + " ur ON ur.user_id = um.user_id AND ur.deleted_at = 0"
+            + " JOIN "
+            + ROLE_TABLE_NAME
+            + " rm ON rm.role_id = ur.role_id AND rm.deleted_at = 0"
+            + " WHERE mm3.metalake_name = #{metalakeName} AND um.user_name = 
#{userName}"
+            + " AND um.deleted_at = 0";
+
+    if (groupNames == null || groupNames.isEmpty()) {
+      // No group memberships → 2-branch query (USER + USER_ROLE only).
+      return "<script>" + userBranch + userRoleBranch + "</script>";
+    }
+
+    // 'GROUP' branch → AuthPrefetchRow.Kind.GROUP.
+    String groupBranch =
+        " UNION ALL "
+            + "SELECT 'GROUP' AS subjectType, gm.group_id AS entityId, 
gm.group_name AS entityName,"
+            + " gm.updated_at AS updatedAt, NULL AS bindingOwnerId"
+            + " FROM "
+            + GROUP_TABLE_NAME
+            + " gm"
+            + " JOIN "
+            + MetalakeMetaMapper.TABLE_NAME
+            + " mm2 ON gm.metalake_id = mm2.metalake_id AND mm2.deleted_at = 0"
+            + " WHERE mm2.metalake_name = #{metalakeName}"
+            + " AND gm.group_name IN"
+            + " <foreach item='g' collection='groupNames' open='(' 
separator=',' close=')'>#{g}</foreach>"
+            + " AND gm.deleted_at = 0";
+
+    // 'GROUP_ROLE' branch → AuthPrefetchRow.Kind.GROUP_ROLE.
+    // bindingOwnerId carries the owning group_id so consumers can bucket 
roles by group.
+    String groupRoleBranch =
+        " UNION ALL "
+            + "SELECT 'GROUP_ROLE' AS subjectType, rm.role_id AS entityId, 
rm.role_name AS entityName,"
+            + " rm.updated_at AS updatedAt, gr.group_id AS bindingOwnerId"
+            + " FROM "
+            + GROUP_TABLE_NAME
+            + " gm"
+            + " JOIN "
+            + MetalakeMetaMapper.TABLE_NAME
+            + " mm4 ON gm.metalake_id = mm4.metalake_id AND mm4.deleted_at = 0"
+            + " JOIN "
+            + GROUP_ROLE_RELATION_TABLE_NAME
+            + " gr ON gr.group_id = gm.group_id AND gr.deleted_at = 0"
+            + " JOIN "
+            + ROLE_TABLE_NAME
+            + " rm ON rm.role_id = gr.role_id AND rm.deleted_at = 0"
+            + " WHERE mm4.metalake_name = #{metalakeName}"
+            + " AND gm.group_name IN"
+            + " <foreach item='g2' collection='groupNames' open='(' 
separator=',' close=')'>#{g2}</foreach>"
+            + " AND gm.deleted_at = 0";
+
+    return "<script>" + userBranch + userRoleBranch + groupBranch + 
groupRoleBranch + "</script>";
+  }
 }
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/OwnerMetaPostgreSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/OwnerMetaPostgreSQLProvider.java
index 06791de3b7..beadb38b3a 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/OwnerMetaPostgreSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/OwnerMetaPostgreSQLProvider.java
@@ -34,36 +34,57 @@ import 
org.apache.gravitino.storage.relational.po.OwnerRelForDeletion;
 import org.apache.ibatis.annotations.Param;
 
 public class OwnerMetaPostgreSQLProvider extends OwnerMetaBaseSQLProvider {
+  @Override
+  protected String currentTimestampMillisExpression() {
+    return "CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000 AS BIGINT)";
+  }
+
   @Override
   public String softDeleteOwnerRelByMetadataObjectIdAndType(
       Long metadataObjectId, String metadataObjectType) {
+    String now = currentTimestampMillisExpression();
     return "UPDATE "
         + OWNER_TABLE_NAME
-        + " SET deleted_at = CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000 
AS BIGINT)"
+        + " SET deleted_at = "
+        + now
+        + ", updated_at = "
+        + now
         + " WHERE metadata_object_id = #{metadataObjectId} AND 
metadata_object_type = #{metadataObjectType} AND deleted_at = 0";
   }
 
   @Override
   public String softDeleteOwnerRelByOwnerIdAndType(Long ownerId, String 
ownerType) {
+    String now = currentTimestampMillisExpression();
     return "UPDATE "
         + OWNER_TABLE_NAME
-        + " SET deleted_at = CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000 
AS BIGINT)"
+        + " SET deleted_at = "
+        + now
+        + ", updated_at = "
+        + now
         + " WHERE owner_id = #{ownerId} AND owner_type = #{ownerType} AND 
deleted_at = 0";
   }
 
   @Override
   public String softDeleteOwnerRelByMetalakeId(Long metalakeId) {
+    String now = currentTimestampMillisExpression();
     return "UPDATE "
         + OWNER_TABLE_NAME
-        + " SET deleted_at = CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000 
AS BIGINT)"
+        + " SET deleted_at = "
+        + now
+        + ", updated_at = "
+        + now
         + " WHERE metalake_id = #{metalakeId} AND deleted_at = 0";
   }
 
   @Override
   public String softDeleteOwnerRelByCatalogId(Long catalogId) {
+    String now = currentTimestampMillisExpression();
     return "UPDATE "
         + OWNER_TABLE_NAME
-        + " ot SET deleted_at = CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 
1000 AS BIGINT)"
+        + " ot SET deleted_at = "
+        + now
+        + ", updated_at = "
+        + now
         + " WHERE ot.deleted_at = 0 AND EXISTS ("
         + " SELECT ct.catalog_id FROM "
         + CatalogMetaMapper.TABLE_NAME
@@ -109,10 +130,14 @@ public class OwnerMetaPostgreSQLProvider extends 
OwnerMetaBaseSQLProvider {
 
   @Override
   public String softDeleteOwnerRelBySchemaIds(@Param("schemaIds") List<Long> 
schemaIds) {
+    String now = currentTimestampMillisExpression();
     return "<script>"
         + "UPDATE "
         + OWNER_TABLE_NAME
-        + " ot SET deleted_at = CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 
1000 AS BIGINT)"
+        + " ot SET deleted_at = "
+        + now
+        + ", updated_at = "
+        + now
         + " WHERE ot.deleted_at = 0 AND EXISTS ("
         + " SELECT st.schema_id FROM "
         + SchemaMetaMapper.TABLE_NAME
@@ -176,10 +201,14 @@ public class OwnerMetaPostgreSQLProvider extends 
OwnerMetaBaseSQLProvider {
   @Override
   public String batchSoftDeleteOwnerRelByMetadataObjects(
       @Param("deletions") List<OwnerRelForDeletion> deletions) {
+    String now = currentTimestampMillisExpression();
     return "<script>"
         + "UPDATE "
         + OWNER_TABLE_NAME
-        + " SET deleted_at = CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000 
AS BIGINT)"
+        + " SET deleted_at = "
+        + now
+        + ", updated_at = "
+        + now
         + " WHERE deleted_at = 0 AND ("
         + "<foreach collection='deletions' item='t' separator=' OR '>"
         + "(metadata_object_id = #{t.metadataObjectId} AND 
metadata_object_type = #{t.metadataObjectType})"
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/po/auth/AuthPrefetchRow.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/po/auth/AuthPrefetchRow.java
new file mode 100644
index 0000000000..a62a11daa7
--- /dev/null
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/po/auth/AuthPrefetchRow.java
@@ -0,0 +1,227 @@
+/*
+ * 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.storage.relational.po.auth;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/**
+ * Polymorphic result row returned by {@code 
UserMetaMapper#batchGetAuthSubjectsForUser}. That
+ * mapper folds four logically distinct fetches (the request user, the user's 
groups, the user's
+ * direct roles, the user's group-inherited roles) into a single round trip 
via {@code UNION ALL},
+ * so its result set is a flat list of rows whose meaning depends on a 
discriminator. This class is
+ * that row.
+ *
+ * <p>Each row carries the same physical columns ({@code subjectType}, {@code 
entityId}, {@code
+ * entityName}, {@code updatedAt}, {@code bindingOwnerId}), but the semantics 
of those columns shift
+ * by {@link Kind}:
+ *
+ * <table>
+ *   <caption>Per-Kind field semantics</caption>
+ *   <tr>
+ *     <th>{@link Kind}</th>
+ *     <th>{@code entityId}</th>
+ *     <th>{@code entityName}</th>
+ *     <th>{@code updatedAt}</th>
+ *     <th>{@code bindingOwnerId}</th>
+ *   </tr>
+ *   <tr>
+ *     <td>{@link Kind#USER}</td>
+ *     <td>{@code user_meta.user_id}</td>
+ *     <td>{@code user_meta.user_name}</td>
+ *     <td>{@code user_meta.updated_at}</td>
+ *     <td>{@code null}</td>
+ *   </tr>
+ *   <tr>
+ *     <td>{@link Kind#GROUP}</td>
+ *     <td>{@code group_meta.group_id}</td>
+ *     <td>{@code group_meta.group_name}</td>
+ *     <td>{@code group_meta.updated_at}</td>
+ *     <td>{@code null}</td>
+ *   </tr>
+ *   <tr>
+ *     <td>{@link Kind#USER_ROLE}</td>
+ *     <td>{@code role_meta.role_id}</td>
+ *     <td>{@code role_meta.role_name}</td>
+ *     <td>{@code role_meta.updated_at}</td>
+ *     <td>owning {@code user_id}</td>
+ *   </tr>
+ *   <tr>
+ *     <td>{@link Kind#GROUP_ROLE}</td>
+ *     <td>{@code role_meta.role_id}</td>
+ *     <td>{@code role_meta.role_name}</td>
+ *     <td>{@code role_meta.updated_at}</td>
+ *     <td>owning {@code group_id}</td>
+ *   </tr>
+ * </table>
+ *
+ * <p>The four kinds mirror the four {@code UNION ALL} branches one-for-one; 
the discriminator is
+ * not an arbitrary taxonomy. The consumer ({@code 
JcasbinAuthorizer#prefetchUserAndGroupInfo})
+ * switches on {@link Kind} and steers each row into the matching collection 
(single user, group
+ * map, user role-id set, group role-ids-by-group-id map).
+ *
+ * <p>Why a polymorphic flat row instead of separate POs:
+ *
+ * <ul>
+ *   <li>{@code UNION ALL} requires all branches to share the same column 
count and types, so the
+ *       physical row shape is necessarily uniform.
+ *   <li>Splitting into separate per-kind POs would mean separate SQL queries 
— losing the single
+ *       round trip that is the whole point of this method.
+ * </ul>
+ *
+ * <p>Prefer the {@code forXxx} factory methods over the all-args constructor 
when building rows in
+ * Java code (tests, ad-hoc fixtures); they document which {@link Kind} each 
shape corresponds to
+ * and avoid mistakes such as passing {@code null} as {@code bindingOwnerId} 
for a {@link
+ * Kind#USER_ROLE} row. The all-args constructor is kept public for MyBatis 
reflective row mapping.
+ */
+@Getter
+@Setter
+@NoArgsConstructor
+@AllArgsConstructor
+public class AuthPrefetchRow {
+
+  /**
+   * Discriminator that determines how the other fields are interpreted. Each 
value maps 1:1 to a
+   * branch of the {@code UNION ALL} in {@code 
UserMetaBaseSQLProvider#batchGetAuthSubjectsForUser}.
+   * See class-level Javadoc for the full per-Kind field table.
+   */
+  public enum Kind {
+    /** A {@code user_meta} row (the request user). {@code bindingOwnerId} is 
{@code null}. */
+    USER,
+    /**
+     * A {@code group_meta} row (one of the request user's groups). {@code 
bindingOwnerId} is {@code
+     * null}.
+     */
+    GROUP,
+    /**
+     * A {@code user_role_rel JOIN role_meta} row: a role bound directly to 
the request user. {@code
+     * entityId} is the {@code role_id}; {@code bindingOwnerId} is the owning 
{@code user_id}.
+     */
+    USER_ROLE,
+    /**
+     * A {@code group_role_rel JOIN role_meta} row: a role inherited via group 
membership. {@code
+     * entityId} is the {@code role_id}; {@code bindingOwnerId} is the owning 
{@code group_id}.
+     */
+    GROUP_ROLE
+  }
+
+  /**
+   * Discriminator field. Populated from the SQL string literal in each {@code 
UNION ALL} branch
+   * ({@code 'USER'}, {@code 'GROUP'}, {@code 'USER_ROLE'}, {@code 
'GROUP_ROLE'}). Drives how the
+   * other fields are interpreted by consumers.
+   */
+  private Kind subjectType;
+
+  /**
+   * Primary entity id for this row. Interpretation depends on {@link 
#subjectType}:
+   *
+   * <ul>
+   *   <li>{@link Kind#USER} → {@code user_meta.user_id}
+   *   <li>{@link Kind#GROUP} → {@code group_meta.group_id}
+   *   <li>{@link Kind#USER_ROLE}, {@link Kind#GROUP_ROLE} → {@code 
role_meta.role_id}
+   * </ul>
+   */
+  private long entityId;
+
+  /**
+   * Primary entity name. Interpretation depends on {@link #subjectType}:
+   *
+   * <ul>
+   *   <li>{@link Kind#USER} → {@code user_name}
+   *   <li>{@link Kind#GROUP} → {@code group_name}
+   *   <li>{@link Kind#USER_ROLE}, {@link Kind#GROUP_ROLE} → {@code role_name}
+   * </ul>
+   */
+  private String entityName;
+
+  /**
+   * {@code updated_at} of the row's primary entity ({@code user_meta} / 
{@code group_meta} / {@code
+   * role_meta}). Used as the cache staleness sentinel by the 
version-validated caches in {@code
+   * JcasbinAuthorizer}.
+   */
+  private long updatedAt;
+
+  /**
+   * Owning subject id for role-binding rows; {@code null} for identity rows:
+   *
+   * <ul>
+   *   <li>{@link Kind#USER_ROLE} → the {@code user_id} the role is directly 
bound to
+   *   <li>{@link Kind#GROUP_ROLE} → the {@code group_id} the role is 
inherited from
+   *   <li>{@link Kind#USER}, {@link Kind#GROUP} → {@code null}
+   * </ul>
+   *
+   * Consumers use this to bucket role rows back onto the owning user/group 
when populating the
+   * downstream per-subject role caches.
+   */
+  private Long bindingOwnerId;
+
+  /**
+   * Builds a {@link Kind#USER} row.
+   *
+   * @param userId {@code user_meta.user_id}
+   * @param userName {@code user_meta.user_name}
+   * @param updatedAt {@code user_meta.updated_at}
+   * @return a populated row
+   */
+  public static AuthPrefetchRow forUser(long userId, String userName, long 
updatedAt) {
+    return new AuthPrefetchRow(Kind.USER, userId, userName, updatedAt, null);
+  }
+
+  /**
+   * Builds a {@link Kind#GROUP} row.
+   *
+   * @param groupId {@code group_meta.group_id}
+   * @param groupName {@code group_meta.group_name}
+   * @param updatedAt {@code group_meta.updated_at}
+   * @return a populated row
+   */
+  public static AuthPrefetchRow forGroup(long groupId, String groupName, long 
updatedAt) {
+    return new AuthPrefetchRow(Kind.GROUP, groupId, groupName, updatedAt, 
null);
+  }
+
+  /**
+   * Builds a {@link Kind#USER_ROLE} row bound to {@code ownerUserId}.
+   *
+   * @param roleId {@code role_meta.role_id}
+   * @param roleName {@code role_meta.role_name}
+   * @param updatedAt {@code role_meta.updated_at}
+   * @param ownerUserId the {@code user_id} that owns this direct role binding
+   * @return a populated row
+   */
+  public static AuthPrefetchRow forUserRole(
+      long roleId, String roleName, long updatedAt, long ownerUserId) {
+    return new AuthPrefetchRow(Kind.USER_ROLE, roleId, roleName, updatedAt, 
ownerUserId);
+  }
+
+  /**
+   * Builds a {@link Kind#GROUP_ROLE} row inherited from {@code ownerGroupId}.
+   *
+   * @param roleId {@code role_meta.role_id}
+   * @param roleName {@code role_meta.role_name}
+   * @param updatedAt {@code role_meta.updated_at}
+   * @param ownerGroupId the {@code group_id} that owns this inherited role 
binding
+   * @return a populated row
+   */
+  public static AuthPrefetchRow forGroupRole(
+      long roleId, String roleName, long updatedAt, long ownerGroupId) {
+    return new AuthPrefetchRow(Kind.GROUP_ROLE, roleId, roleName, updatedAt, 
ownerGroupId);
+  }
+}
diff --git 
a/core/src/test/java/org/apache/gravitino/hook/TestAccessControlHookDispatcher.java
 
b/core/src/test/java/org/apache/gravitino/hook/TestAccessControlHookDispatcher.java
index d0d1f1519e..6f5ac0d404 100644
--- 
a/core/src/test/java/org/apache/gravitino/hook/TestAccessControlHookDispatcher.java
+++ 
b/core/src/test/java/org/apache/gravitino/hook/TestAccessControlHookDispatcher.java
@@ -19,6 +19,7 @@
 package org.apache.gravitino.hook;
 
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
@@ -28,8 +29,11 @@ import java.util.Collections;
 import org.apache.commons.lang3.reflect.FieldUtils;
 import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.authorization.AccessControlDispatcher;
+import org.apache.gravitino.authorization.GravitinoAuthorizer;
+import org.apache.gravitino.authorization.Group;
 import org.apache.gravitino.authorization.OwnerDispatcher;
 import org.apache.gravitino.authorization.Role;
+import org.apache.gravitino.authorization.User;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeEach;
@@ -40,16 +44,21 @@ public class TestAccessControlHookDispatcher {
   private AccessControlHookDispatcher hookDispatcher;
   private AccessControlDispatcher mockDispatcher;
   private OwnerDispatcher mockOwnerDispatcher;
+  private GravitinoAuthorizer mockAuthorizer;
   // Save the original ownerDispatcher before each test and restore it in 
tearDown so we do not
   // leak null state into the GravitinoEnv singleton across tests.
   private OwnerDispatcher savedOwnerDispatcher;
+  private GravitinoAuthorizer savedAuthorizer;
 
   @BeforeEach
   public void setUp() throws IllegalAccessException {
     mockDispatcher = mock(AccessControlDispatcher.class);
     mockOwnerDispatcher = mock(OwnerDispatcher.class);
+    mockAuthorizer = mock(GravitinoAuthorizer.class);
     savedOwnerDispatcher = GravitinoEnv.getInstance().ownerDispatcher();
+    savedAuthorizer = GravitinoEnv.getInstance().gravitinoAuthorizer();
     FieldUtils.writeField(GravitinoEnv.getInstance(), "ownerDispatcher", 
mockOwnerDispatcher, true);
+    FieldUtils.writeField(GravitinoEnv.getInstance(), "gravitinoAuthorizer", 
mockAuthorizer, true);
     hookDispatcher = new AccessControlHookDispatcher(mockDispatcher);
   }
 
@@ -57,6 +66,7 @@ public class TestAccessControlHookDispatcher {
   public void tearDown() throws IllegalAccessException {
     FieldUtils.writeField(
         GravitinoEnv.getInstance(), "ownerDispatcher", savedOwnerDispatcher, 
true);
+    FieldUtils.writeField(GravitinoEnv.getInstance(), "gravitinoAuthorizer", 
savedAuthorizer, true);
   }
 
   @Test
@@ -77,4 +87,32 @@ public class TestAccessControlHookDispatcher {
     Assertions.assertEquals("Set owner failed", thrown.getMessage());
     verify(mockDispatcher).createRole(any(), any(), any(), any());
   }
+
+  @Test
+  public void testGrantRolesToUserInvalidatesUserRoleRelation() {
+    User mockUser = mock(User.class);
+    when(mockDispatcher.grantRolesToUser(
+            eq("test_metalake"), eq(Collections.singletonList("test_role")), 
eq("test_user")))
+        .thenReturn(mockUser);
+
+    hookDispatcher.grantRolesToUser(
+        "test_metalake", Collections.singletonList("test_role"), "test_user");
+
+    verify(mockAuthorizer).handleRolePrivilegeChange("test_metalake", 
"test_role");
+    verify(mockAuthorizer).handleUserRoleRelChange("test_metalake", 
"test_user");
+  }
+
+  @Test
+  public void testGrantRolesToGroupInvalidatesGroupRoleRelation() {
+    Group mockGroup = mock(Group.class);
+    when(mockDispatcher.grantRolesToGroup(
+            eq("test_metalake"), eq(Collections.singletonList("test_role")), 
eq("test_group")))
+        .thenReturn(mockGroup);
+
+    hookDispatcher.grantRolesToGroup(
+        "test_metalake", Collections.singletonList("test_role"), "test_group");
+
+    verify(mockAuthorizer).handleRolePrivilegeChange("test_metalake", 
"test_role");
+    verify(mockAuthorizer).handleGroupRoleRelChange("test_metalake", 
"test_group");
+  }
 }
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestAuthMappers.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestAuthMappers.java
index cb02de4aa1..2175a0a6b5 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestAuthMappers.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestAuthMappers.java
@@ -337,6 +337,19 @@ public class TestAuthMappers {
             .withUpdatedAt(0L)
             .build();
     ownerMetaMapper.insertOwnerRel(ownerRelPO);
+    ownerMetaMapper.insertOwnerRel(
+        OwnerRelPO.builder()
+            .withMetalakeId(1L)
+            .withOwnerId(60L)
+            .withOwnerType("USER")
+            .withMetadataObjectId(201L)
+            .withMetadataObjectType("SCHEMA")
+            .withAuditIfo(auditInfo.toString())
+            .withCurrentVersion(1L)
+            .withLastVersion(0L)
+            .withDeleteAt(200L)
+            .withUpdatedAt(200L)
+            .build());
 
     // Set updated_at = 100 via direct SQL
     try (SqlSession sqlSession =
@@ -351,16 +364,27 @@ public class TestAuthMappers {
       throw new RuntimeException("Update failed", e);
     }
 
-    List<ChangedOwnerInfo> changed = ownerMetaMapper.selectChangedOwners(0L);
-    Assertions.assertEquals(1, changed.size());
+    List<ChangedOwnerInfo> changed = ownerMetaMapper.selectChangedOwners(0L, 
0L);
+    Assertions.assertEquals(2, changed.size());
     Assertions.assertEquals(200L, changed.get(0).getMetadataObjectId());
     Assertions.assertEquals("SCHEMA", changed.get(0).getMetadataObjectType());
     Assertions.assertEquals(100L, changed.get(0).getUpdatedAt());
+    Assertions.assertEquals(201L, changed.get(1).getMetadataObjectId());
+    Assertions.assertEquals("SCHEMA", changed.get(1).getMetadataObjectType());
+    Assertions.assertEquals(200L, changed.get(1).getUpdatedAt());
 
-    // Polling after the last seen id should not return the same row again.
+    // Polling after the last seen tuple should not return the same row again.
     List<ChangedOwnerInfo> sameTimestamp =
-        ownerMetaMapper.selectChangedOwners(changed.get(0).getId());
+        ownerMetaMapper.selectChangedOwners(changed.get(1).getUpdatedAt(), 
changed.get(1).getId());
     Assertions.assertTrue(sameTimestamp.isEmpty());
+
+    ownerMetaMapper.softDeleteOwnerRelByOwnerIdAndType(60L, "USER");
+    List<ChangedOwnerInfo> softDeleted =
+        ownerMetaMapper.selectChangedOwners(changed.get(1).getUpdatedAt(), 
changed.get(1).getId());
+    Assertions.assertEquals(1, softDeleted.size());
+    Assertions.assertEquals(200L, softDeleted.get(0).getMetadataObjectId());
+    Assertions.assertEquals("SCHEMA", 
softDeleted.get(0).getMetadataObjectType());
+    Assertions.assertTrue(softDeleted.get(0).getUpdatedAt() > 
changed.get(1).getUpdatedAt());
   }
 
   private AuditInfo buildAuditInfo() {
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 6033fc52a1..73345e1340 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
@@ -37,6 +37,7 @@ 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.Namespace;
@@ -46,6 +47,7 @@ import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.BaseMetalake;
 import org.apache.gravitino.meta.CatalogEntity;
 import org.apache.gravitino.meta.FilesetEntity;
+import org.apache.gravitino.meta.GroupEntity;
 import org.apache.gravitino.meta.RoleEntity;
 import org.apache.gravitino.meta.SchemaEntity;
 import org.apache.gravitino.meta.TableEntity;
@@ -56,6 +58,7 @@ import 
org.apache.gravitino.storage.relational.TestJDBCBackend;
 import org.apache.gravitino.storage.relational.mapper.RoleMetaMapper;
 import org.apache.gravitino.storage.relational.mapper.UserMetaMapper;
 import org.apache.gravitino.storage.relational.po.RolePO;
+import org.apache.gravitino.storage.relational.po.auth.AuthPrefetchRow;
 import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
 import org.apache.gravitino.storage.relational.utils.SessionUtils;
 import org.apache.gravitino.utils.NamespaceUtil;
@@ -1162,6 +1165,101 @@ class TestUserMetaService extends TestJDBCBackend {
     Assertions.assertEquals(0, deletedCount); // no more to delete
   }
 
+  @TestTemplate
+  void batchGetAuthSubjectsForUser() throws IOException {
+    AuditInfo auditInfo =
+        
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build();
+    BaseMetalake metalake =
+        createBaseMakeLake(RandomIdGenerator.INSTANCE.nextId(), metalakeName, 
auditInfo);
+    backend.insert(metalake, false);
+
+    UserMetaService userMetaService = UserMetaService.getInstance();
+    GroupMetaService groupMetaService = GroupMetaService.getInstance();
+
+    UserEntity user =
+        createUserEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            AuthorizationUtils.ofUserNamespace(metalakeName),
+            "batchUser",
+            auditInfo);
+    userMetaService.insertUser(user, false);
+
+    GroupEntity groupA =
+        createGroupEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            AuthorizationUtils.ofGroupNamespace(metalakeName),
+            "batchGroupA",
+            auditInfo,
+            Collections.emptyList(),
+            Collections.emptyList());
+    GroupEntity groupB =
+        createGroupEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            AuthorizationUtils.ofGroupNamespace(metalakeName),
+            "batchGroupB",
+            auditInfo,
+            Collections.emptyList(),
+            Collections.emptyList());
+    groupMetaService.insertGroup(groupA, false);
+    groupMetaService.insertGroup(groupB, false);
+
+    // Case 1: user + multiple groups, no role bindings → one row per subject.
+    List<AuthPrefetchRow> rows =
+        SessionUtils.getWithoutCommit(
+            UserMetaMapper.class,
+            m ->
+                m.batchGetAuthSubjectsForUser(
+                    metalakeName, "batchUser", 
Lists.newArrayList("batchGroupA", "batchGroupB")));
+    assertEquals(3, rows.size());
+    AuthPrefetchRow userRow =
+        rows.stream()
+            .filter(r -> AuthPrefetchRow.Kind.USER == r.getSubjectType())
+            .findFirst()
+            .orElseThrow();
+    assertEquals(user.id(), userRow.getEntityId());
+    assertEquals("batchUser", userRow.getEntityName());
+    assertTrue(userRow.getUpdatedAt() >= 0);
+    List<AuthPrefetchRow> groupRows =
+        rows.stream()
+            .filter(r -> AuthPrefetchRow.Kind.GROUP == r.getSubjectType())
+            .sorted(Comparator.comparing(AuthPrefetchRow::getEntityName))
+            .collect(Collectors.toList());
+    assertEquals(2, groupRows.size());
+    assertEquals("batchGroupA", groupRows.get(0).getEntityName());
+    assertEquals(groupA.id(), groupRows.get(0).getEntityId());
+    assertEquals("batchGroupB", groupRows.get(1).getEntityName());
+    assertEquals(groupB.id(), groupRows.get(1).getEntityId());
+
+    // Case 2: empty group list → user-only branch (no GROUP / GROUP_ROLE 
UNION).
+    List<AuthPrefetchRow> userOnly =
+        SessionUtils.getWithoutCommit(
+            UserMetaMapper.class,
+            m -> m.batchGetAuthSubjectsForUser(metalakeName, "batchUser", 
Collections.emptyList()));
+    assertEquals(1, userOnly.size());
+    assertEquals(AuthPrefetchRow.Kind.USER, userOnly.get(0).getSubjectType());
+    assertEquals(user.id(), userOnly.get(0).getEntityId());
+
+    // Case 3: missing user + missing group → only the present group row is 
returned.
+    List<AuthPrefetchRow> missing =
+        SessionUtils.getWithoutCommit(
+            UserMetaMapper.class,
+            m ->
+                m.batchGetAuthSubjectsForUser(
+                    metalakeName, "noSuchUser", 
Lists.newArrayList("noSuchGroup", "batchGroupA")));
+    assertEquals(1, missing.size(), "Only the existing group should be 
returned");
+    assertEquals(AuthPrefetchRow.Kind.GROUP, missing.get(0).getSubjectType());
+    assertEquals("batchGroupA", missing.get(0).getEntityName());
+
+    // Case 4: both user and groups missing → empty result.
+    List<AuthPrefetchRow> none =
+        SessionUtils.getWithoutCommit(
+            UserMetaMapper.class,
+            m ->
+                m.batchGetAuthSubjectsForUser(
+                    metalakeName, "noSuchUser", 
Lists.newArrayList("noSuchGroup")));
+    assertTrue(none.isEmpty());
+  }
+
   private Integer countUsers(Long metalakeId) {
     int count = 0;
     try (SqlSession sqlSession =
diff --git 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/PassThroughAuthorizer.java
 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/PassThroughAuthorizer.java
index 76de95b0d3..c58ffea12e 100644
--- 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/PassThroughAuthorizer.java
+++ 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/PassThroughAuthorizer.java
@@ -74,7 +74,10 @@ public class PassThroughAuthorizer implements 
GravitinoAuthorizer {
   }
 
   @Override
-  public boolean isSelf(Entity.EntityType type, NameIdentifier nameIdentifier) 
{
+  public boolean isSelf(
+      Entity.EntityType type,
+      NameIdentifier nameIdentifier,
+      AuthorizationRequestContext requestContext) {
     return true;
   }
 
diff --git 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConverter.java
 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConverter.java
index 4a4a58a599..f98ea1ea02 100644
--- 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConverter.java
+++ 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConverter.java
@@ -112,7 +112,8 @@ public class AuthorizationExpressionConverter {
             } else if (AuthConstants.SELF.equals(privilegeOrExpression)) {
               replacement =
                   String.format(
-                      
"authorizer.isSelf(@org.apache.gravitino.Entity\\$EntityType@%s,%s_NAME_IDENT)",
+                      
"authorizer.isSelf(@org.apache.gravitino.Entity\\$EntityType@%s,"
+                          + "%s_NAME_IDENT,authorizationContext)",
                       type, type);
             } else {
               replacement =
diff --git 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizationLookups.java
 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizationLookups.java
index 6a1b30c643..5ff033f5e1 100644
--- 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizationLookups.java
+++ 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizationLookups.java
@@ -94,8 +94,9 @@ public class JcasbinAuthorizationLookups {
 
   /**
    * Two-tier owner lookup: request-level dedup first, then the shared {@code 
ownerRelCache}, and
-   * finally a single {@code owner_meta} query. A successful DB fetch 
populates both tiers so
-   * subsequent {@code isOwner} calls — in this request and later ones — hit 
the cache.
+   * finally a single {@code owner_meta} query. Positive DB fetches populate 
both tiers; missing
+   * owners are cached only for the current request to avoid pinning a 
cross-request negative result
+   * through a missed invalidation.
    */
   public Optional<OwnerInfo> resolveOwnerId(
       Long metadataId,
@@ -103,15 +104,32 @@ public class JcasbinAuthorizationLookups {
       AuthorizationRequestContext requestContext) {
     return requestContext.computeOwnerIfAbsent(
         metadataId,
-        id ->
-            ownerRelCache.get(
-                id,
-                ignored -> {
-                  OwnerInfo ownerInfo =
-                      SessionUtils.getWithoutCommit(
-                          OwnerMetaMapper.class,
-                          m -> m.selectOwnerByMetadataObjectIdAndType(id, 
metadataType.name()));
-                  return ownerInfo == null ? Optional.empty() : 
Optional.of(ownerInfo);
-                }));
+        id -> {
+          try {
+            // Use the cache's atomic loader so concurrent misses on the same 
id collapse to one DB
+            // query. The loader throws for missing owners so only positive 
results land in the
+            // long-lived cache; negatives are confined to the per-request map 
above.
+            return ownerRelCache.get(id, k -> loadOwner(k, metadataType));
+          } catch (NoSuchOwnerException e) {
+            return Optional.empty();
+          }
+        });
+  }
+
+  private static Optional<OwnerInfo> loadOwner(Long id, MetadataObject.Type 
metadataType) {
+    OwnerInfo ownerInfo =
+        SessionUtils.getWithoutCommit(
+            OwnerMetaMapper.class,
+            m -> m.selectOwnerByMetadataObjectIdAndType(id, 
metadataType.name()));
+    if (ownerInfo == null) {
+      throw new NoSuchOwnerException();
+    }
+    return Optional.of(ownerInfo);
+  }
+
+  private static final class NoSuchOwnerException extends RuntimeException {
+    private NoSuchOwnerException() {
+      super(null, null, false, false);
+    }
   }
 }
diff --git 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizer.java
 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizer.java
index da67d85eea..0af8e33355 100644
--- 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizer.java
+++ 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizer.java
@@ -25,7 +25,9 @@ import java.nio.charset.StandardCharsets;
 import java.security.Principal;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.HashMap;
 import java.util.HashSet;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
@@ -43,7 +45,6 @@ import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.MetadataObjects;
 import org.apache.gravitino.NameIdentifier;
-import org.apache.gravitino.SupportsRelationOperations;
 import org.apache.gravitino.UserGroup;
 import org.apache.gravitino.UserPrincipal;
 import org.apache.gravitino.auth.AuthConstants;
@@ -61,6 +62,7 @@ import 
org.apache.gravitino.storage.relational.mapper.GroupMetaMapper;
 import org.apache.gravitino.storage.relational.mapper.RoleMetaMapper;
 import org.apache.gravitino.storage.relational.mapper.UserMetaMapper;
 import org.apache.gravitino.storage.relational.po.RolePO;
+import org.apache.gravitino.storage.relational.po.auth.AuthPrefetchRow;
 import org.apache.gravitino.storage.relational.po.auth.GroupUpdatedAt;
 import org.apache.gravitino.storage.relational.po.auth.OwnerInfo;
 import org.apache.gravitino.storage.relational.po.auth.RoleUpdatedAt;
@@ -359,7 +361,10 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
   }
 
   @Override
-  public boolean isSelf(Entity.EntityType type, NameIdentifier nameIdentifier) 
{
+  public boolean isSelf(
+      Entity.EntityType type,
+      NameIdentifier nameIdentifier,
+      AuthorizationRequestContext requestContext) {
     String metalake = nameIdentifier.namespace().level(0);
     String currentUserName = PrincipalUtils.getCurrentUserName();
     if (Entity.EntityType.USER == type) {
@@ -372,26 +377,24 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
         if (!roleId.isPresent()) {
           return false;
         }
-        EntityStore entityStore = GravitinoEnv.getInstance().entityStore();
-        NameIdentifier userNameIdentifier =
-            NameIdentifierUtil.ofUser(metalake, 
PrincipalUtils.getCurrentUserName());
-        List<RoleEntity> entities =
-            entityStore
-                .relationOperations()
-                .listEntitiesByRelation(
-                    SupportsRelationOperations.Type.ROLE_USER_REL,
-                    userNameIdentifier,
-                    Entity.EntityType.USER);
         long resolvedRoleId = roleId.get();
-        // Check direct user-role assignment
-        if (entities.stream()
-            .anyMatch(roleEntity -> Objects.equals(roleEntity.id(), 
resolvedRoleId))) {
+
+        Optional<UserUpdatedAt> userInfoOpt =
+            loadUserInfo(metalake, currentUserName, requestContext);
+        if (!userInfoOpt.isPresent()) {
+          return false;
+        }
+        UserUpdatedAt userInfo = userInfoOpt.get();
+        long userId = userInfo.getUserId();
+
+        List<Long> directRoleIds = loadUserRoles(metalake, currentUserName, 
userId, userInfo);
+        if (directRoleIds.contains(resolvedRoleId)) {
           return true;
         }
-        // Check group-role assignments.
-        for (GroupEntity groupEntity : resolveCurrentUserGroups(metalake, 
entityStore)) {
-          List<Long> groupRoleIds = groupEntity.roleIds();
-          if (groupRoleIds != null && groupRoleIds.contains(resolvedRoleId)) {
+
+        for (String groupname : currentPrincipalGroupNames()) {
+          List<Long> groupRoleIds = loadGroupRoles(metalake, groupname, 
userId, requestContext);
+          if (groupRoleIds.contains(resolvedRoleId)) {
             return true;
           }
         }
@@ -509,6 +512,16 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
     loadedRoles.invalidate(roleId);
   }
 
+  @Override
+  public void handleUserRoleRelChange(String metalake, String userName) {
+    
userRoleCache.invalidate(JcasbinAuthorizationCacheKeys.userRoleKey(metalake, 
userName));
+  }
+
+  @Override
+  public void handleGroupRoleRelChange(String metalake, String groupName) {
+    
groupRoleCache.invalidate(JcasbinAuthorizationCacheKeys.groupRoleKey(metalake, 
groupName));
+  }
+
   @Override
   public void handleMetadataOwnerChange(
       String metalake, Long oldOwnerId, NameIdentifier nameIdentifier, 
Entity.EntityType type) {
@@ -609,12 +622,24 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
         MetadataObject metadataObject,
         String privilege,
         AuthorizationRequestContext requestContext) {
+      // OWNER does not consult JCasbin policies — it short-circuits to the 
owner cache in
+      // authorizeByJcasbin. Skip the fat prefetch and role-binding work when 
no non-OWNER
+      // privilege has been evaluated yet in this request.
+      boolean ownerOnly =
+          AuthConstants.OWNER.equals(privilege)
+              && requestContext.getPrefetchedRoleVersions() == null;
+
       long userId;
       UserUpdatedAt userInfo;
       try {
-        // Step 1a: lightweight query — get userId + user.updated_at (version 
sentinel).
-        //          Per-request dedup: only the first authorize() call for 
this user hits DB.
-        Optional<UserUpdatedAt> userInfoOpt = loadUserInfo(metalake, username, 
requestContext);
+        Optional<UserUpdatedAt> userInfoOpt;
+        if (ownerOnly || requestContext.getPrefetchedRoleVersions() != null) {
+          userInfoOpt = loadUserInfo(metalake, username, requestContext);
+        } else {
+          userInfoOpt =
+              prefetchUserAndGroupInfo(
+                  metalake, username, currentPrincipalGroupNames(), 
requestContext);
+        }
         if (!userInfoOpt.isPresent()) {
           LOG.debug("User {} not found in metalake {}", username, metalake);
           return false;
@@ -626,8 +651,11 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
         return false;
       }
 
-      // Steps 1b→3: version-validated role loading — pass userInfo to avoid 
re-query
-      loadRolePrivilege(metalake, username, userId, userInfo, requestContext);
+      if (!ownerOnly) {
+        // Steps 1b→3: version-validated role loading (skipped for OWNER-only 
requests since
+        // the enforcer is not consulted on the OWNER short-circuit).
+        loadRolePrivilege(metalake, username, userId, userInfo, 
requestContext);
+      }
 
       // For requests such as CREATE SCHEMA, the metadata object may be null. 
This method
       // performs object-scoped authorization, so without a metadata object it 
cannot evaluate
@@ -714,6 +742,123 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
                     UserMetaMapper.class, m -> m.getUserUpdatedAt(metalake, 
username))));
   }
 
+  /**
+   * Fat-JOIN prefetch: collapses {@link #loadUserInfo}, per-group {@link 
#loadGroupInfo}, the
+   * per-user/per-group role-list lookups inside {@link #loadUserRoles} / 
{@link #loadGroupRoles},
+   * AND the role-version probe inside {@link #versionCheckAndLoadRoles} into 
a single SQL round
+   * trip. After this returns, the following caches are primed and the rest of 
the authorize hot
+   * path needs zero DB round trips when the cached role policies are still 
current:
+   *
+   * <ul>
+   *   <li>{@code requestContext.userInfoCache} — user version sentinel.
+   *   <li>{@code requestContext.groupInfoCache} — per-group version sentinel; 
absent groups are
+   *       negative-cached so callers can short-circuit.
+   *   <li>{@code userRoleCache} (process-wide) — refreshed with the user's 
current direct role ids
+   *       at the just-read user version, so the next {@link #loadUserRoles} 
call observes a
+   *       version-validated cache hit.
+   *   <li>{@code groupRoleCache} (process-wide) — same idea per group.
+   *   <li>{@code requestContext.prefetchedRoleVersions} — roleId → {@link 
RoleUpdatedAt} map
+   *       consumed by {@link #versionCheckAndLoadRoles} to skip its dedicated 
probe.
+   * </ul>
+   *
+   * <p>The fat prefetch runs at most once per request, gated by {@code 
prefetchedRoleVersions}.
+   */
+  private Optional<UserUpdatedAt> prefetchUserAndGroupInfo(
+      String metalake,
+      String username,
+      List<String> groupNames,
+      AuthorizationRequestContext requestContext) {
+
+    String userKey = JcasbinAuthorizationCacheKeys.userRoleKey(metalake, 
username);
+    if (requestContext.getPrefetchedRoleVersions() != null) {
+      return loadUserInfo(metalake, username, requestContext);
+    }
+
+    // Single round-trip pulls the request user, its groups, and both direct + 
inherited role
+    // bindings as one flat polymorphic list. See AuthPrefetchRow for the 
per-Kind field layout.
+    List<AuthPrefetchRow> rows =
+        SessionUtils.getWithoutCommit(
+            UserMetaMapper.class,
+            m -> m.batchGetAuthSubjectsForUser(metalake, username, 
groupNames));
+
+    UserUpdatedAt foundUser = null;
+    Map<String, GroupUpdatedAt> foundGroups = new HashMap<>();
+    Map<Long, RoleUpdatedAt> roleVersions = new HashMap<>();
+    LinkedHashSet<Long> userRoleIds = new LinkedHashSet<>();
+    Map<Long, LinkedHashSet<Long>> groupRoleIdsByGroupId = new HashMap<>();
+
+    // Pivot the flat row list into per-Kind buckets. Each branch reads 
exactly the fields the
+    // class-level Javadoc of AuthPrefetchRow documents as meaningful for that 
Kind.
+    for (AuthPrefetchRow row : rows) {
+      switch (row.getSubjectType()) {
+        case USER:
+          // entityId = user_id, updatedAt = user_meta.updated_at. At most one 
row.
+          foundUser = new UserUpdatedAt(row.getEntityId(), row.getUpdatedAt());
+          break;
+        case GROUP:
+          // entityId = group_id, entityName = group_name, updatedAt = 
group_meta.updated_at.
+          foundGroups.put(
+              row.getEntityName(), new GroupUpdatedAt(row.getEntityId(), 
row.getUpdatedAt()));
+          break;
+        case USER_ROLE:
+          // entityId = role_id, entityName = role_name, updatedAt = 
role_meta.updated_at.
+          // bindingOwnerId is the user this role is bound to; not needed here 
because the user is
+          // implicit (we already know `username`).
+          userRoleIds.add(row.getEntityId());
+          roleVersions.put(
+              row.getEntityId(),
+              new RoleUpdatedAt(row.getEntityId(), row.getEntityName(), 
row.getUpdatedAt()));
+          break;
+        case GROUP_ROLE:
+          // entityId = role_id, entityName = role_name, updatedAt = 
role_meta.updated_at.
+          // bindingOwnerId = owning group_id — used to bucket roles back to 
their group.
+          Long parentGroupId = row.getBindingOwnerId();
+          if (parentGroupId != null) {
+            groupRoleIdsByGroupId
+                .computeIfAbsent(parentGroupId, p -> new LinkedHashSet<>())
+                .add(row.getEntityId());
+          }
+          roleVersions.put(
+              row.getEntityId(),
+              new RoleUpdatedAt(row.getEntityId(), row.getEntityName(), 
row.getUpdatedAt()));
+          break;
+        default:
+          break;
+      }
+    }
+
+    Optional<UserUpdatedAt> foundUserOpt = Optional.ofNullable(foundUser);
+    requestContext.computeUserInfoIfAbsent(userKey, k -> foundUserOpt);
+
+    for (String groupName : groupNames) {
+      String groupKey = JcasbinAuthorizationCacheKeys.groupRoleKey(metalake, 
groupName);
+      final Optional<GroupUpdatedAt> groupValue = 
Optional.ofNullable(foundGroups.get(groupName));
+      requestContext.computeGroupInfoIfAbsent(groupKey, gk -> groupValue);
+    }
+
+    if (foundUser != null) {
+      userRoleCache.put(
+          JcasbinAuthorizationCacheKeys.userRoleKey(metalake, username),
+          new CachedUserRoleRels(
+              foundUser.getUserId(), foundUser.getUpdatedAt(), new 
ArrayList<>(userRoleIds)));
+    }
+
+    for (Map.Entry<String, GroupUpdatedAt> e : foundGroups.entrySet()) {
+      String gname = e.getKey();
+      GroupUpdatedAt ginfo = e.getValue();
+      LinkedHashSet<Long> ridSet =
+          groupRoleIdsByGroupId.getOrDefault(ginfo.getGroupId(), new 
LinkedHashSet<>());
+      groupRoleCache.put(
+          JcasbinAuthorizationCacheKeys.groupRoleKey(metalake, gname),
+          new CachedGroupRoleRels(
+              ginfo.getGroupId(), ginfo.getUpdatedAt(), new 
ArrayList<>(ridSet)));
+    }
+
+    requestContext.setPrefetchedRoleVersions(roleVersions);
+
+    return foundUserOpt;
+  }
+
   /**
    * Returns true when the cached owner type and ID match the given principal 
or one of the
    * principal's groups. The user id is resolved via the version-validated 
{@link #loadUserInfo}
@@ -893,8 +1038,8 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
 
   /**
    * Resolves GroupEntity objects for the current principal's groups, skipping 
any that are stale or
-   * not found in the store. Used by {@link #isSelf} (ROLE branch) and owner 
checks that need full
-   * group entities instead of only group names.
+   * not found in the store. Used by owner checks that need full group 
entities instead of only
+   * group names.
    */
   private List<GroupEntity> resolveCurrentUserGroups(String metalake, 
EntityStore entityStore) {
     Principal principal = PrincipalUtils.getCurrentPrincipal();
@@ -914,18 +1059,43 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
 
   private void versionCheckAndLoadRoles(
       String metalake, List<Long> roleIds, AuthorizationRequestContext 
requestContext) {
-    // Step 3: batch fetch (roleId, roleName, updated_at) for all role IDs — 1 
query
     List<Long> uniqueRoleIds = 
roleIds.stream().distinct().collect(Collectors.toList());
-    List<RoleUpdatedAt> roleVersions =
-        SessionUtils.getWithoutCommit(
-            RoleMetaMapper.class, m -> m.batchGetRoleUpdatedAt(uniqueRoleIds));
+
+    Map<Long, RoleUpdatedAt> prefetched = 
requestContext.getPrefetchedRoleVersions();
+    List<RoleUpdatedAt> roleVersions = new ArrayList<>(uniqueRoleIds.size());
+    List<Long> missingRoleIds = new ArrayList<>();
+    for (Long rid : uniqueRoleIds) {
+      RoleUpdatedAt rv = prefetched == null ? null : prefetched.get(rid);
+      if (rv != null) {
+        roleVersions.add(rv);
+      } else {
+        missingRoleIds.add(rid);
+      }
+    }
+    if (!missingRoleIds.isEmpty()) {
+      roleVersions.addAll(
+          SessionUtils.getWithoutCommit(
+              RoleMetaMapper.class, m -> 
m.batchGetRoleUpdatedAt(missingRoleIds)));
+    }
+
+    // Any roleId asked about but not returned has been deleted in the DB; 
clear its policies so
+    // a stale grouping row in the enforcer can't keep granting privileges 
before the next
+    // userRoleCache reload prunes the g-row itself.
+    Set<Long> existingRoleIds = new HashSet<>(roleVersions.size());
+    for (RoleUpdatedAt rv : roleVersions) {
+      existingRoleIds.add(rv.getRoleId());
+    }
+    for (Long roleId : uniqueRoleIds) {
+      if (!existingRoleIds.contains(roleId)) {
+        clearRolePolicies(roleId);
+        loadedRoles.invalidate(roleId);
+      }
+    }
 
     List<RoleUpdatedAt> staleRoleVersions = new ArrayList<>();
     for (RoleUpdatedAt rv : roleVersions) {
       Optional<Long> cachedUpdatedAt = 
loadedRoles.getIfPresent(rv.getRoleId());
-
       if (cachedUpdatedAt.isPresent() && cachedUpdatedAt.get() >= 
rv.getUpdatedAt()) {
-        // Role policies are still current
         continue;
       }
       staleRoleVersions.add(rv);
@@ -950,7 +1120,10 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
     if (roleEntities == null) {
       roleEntities = new ArrayList<>();
     }
+    // Some EntityStore implementations don't support batchGet for ROLE and 
return empty;
+    // fall back to per-role get so policies still load.
     if (roleEntities.isEmpty()) {
+      roleEntities = new ArrayList<>(staleRoleVersions.size());
       for (RoleUpdatedAt rv : staleRoleVersions) {
         try {
           roleEntities.add(
@@ -967,6 +1140,9 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
     Map<Long, RoleUpdatedAt> staleRoleVersionById =
         
staleRoleVersions.stream().collect(Collectors.toMap(RoleUpdatedAt::getRoleId, 
rv -> rv));
     for (RoleEntity roleEntity : roleEntities) {
+      if (roleEntity == null) {
+        continue;
+      }
       RoleUpdatedAt rv = staleRoleVersionById.get(roleEntity.id());
       if (rv == null) {
         continue;
@@ -975,8 +1151,8 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
       long dbUpdatedAt = rv.getUpdatedAt();
       Optional<Long> cachedUpdatedAt = loadedRoles.getIfPresent(roleId);
 
-      // Stale or missing: refresh only permission policies. Do not call 
deleteRole here because it
-      // also removes the current user's freshly bound grouping links.
+      // Refresh only permission policies. deleteRole would also remove the 
current user's freshly
+      // bound grouping links.
       if (cachedUpdatedAt.isPresent()) {
         clearRolePolicies(roleId);
       }
diff --git 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinChangePoller.java
 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinChangePoller.java
index 5399e45f24..2786cd52ac 100644
--- 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinChangePoller.java
+++ 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinChangePoller.java
@@ -65,7 +65,13 @@ public class JcasbinChangePoller implements AutoCloseable {
   private final long pollIntervalSecs;
 
   private ScheduledExecutorService scheduler;
-  private volatile long ownerPollHighWaterId = 0;
+
+  // (ownerPollHighWaterUpdatedAt, ownerPollHighWaterUpdatedAtId) is a single 
logical keyset cursor
+  // over owner_meta. Inserts set updated_at = now (in POConverters) and 
soft-deletes set it via
+  // SQL, so every write advances updated_at — one cursor catches both. id is 
the tiebreaker for
+  // batch soft-deletes (softDeleteOwnerRelByCatalogId etc.) where many rows 
share the same ms.
+  private volatile long ownerPollHighWaterUpdatedAt = 0;
+  private volatile long ownerPollHighWaterUpdatedAtId = 0;
   private volatile long entityPollHighWaterId = 0;
 
   /**
@@ -87,19 +93,18 @@ public class JcasbinChangePoller implements AutoCloseable {
    * Initializes the high-water cursors to the current DB tail (so startup 
does not scan historical
    * changes) and schedules periodic polling.
    *
-   * <p>Known trade-off: an id-based high-water mark can miss rows whose id is 
allocated before the
-   * cursor snapshot but whose commit lands after it. Concretely, if writer A 
holds {@code id=N-1}
-   * uncommitted while writer B commits {@code id=N}, {@code 
selectMaxChangeId()} returns N and the
-   * next poll queries {@code id > N} — A's row is never consumed. In that 
case the affected cache
-   * entry stays stale until either (a) a request-side path catches it on the 
next request, or (b)
-   * TTL eviction. Acceptable for the eventual-consistency caches targeted 
here; revisit if we ever
-   * route strong-consistency data through this poller.
+   * <p>The owner poller advances a single {@code (updated_at, id)} keyset 
cursor. {@code id} alone
+   * would miss soft-delete updates (which reuse the row id), and {@code 
updated_at} alone would
+   * miss same-millisecond rows from batch soft-deletes (which all share one 
{@code updated_at}).
    */
   public void start() {
-    ownerPollHighWaterId =
-        getOrDefault(
-            SessionUtils.getWithoutCommit(
-                OwnerMetaMapper.class, OwnerMetaMapper::selectMaxChangeId));
+    ChangedOwnerInfo maxOwnerChange =
+        SessionUtils.getWithoutCommit(
+            OwnerMetaMapper.class, OwnerMetaMapper::selectMaxChangedOwner);
+    if (maxOwnerChange != null) {
+      ownerPollHighWaterUpdatedAt = maxOwnerChange.getUpdatedAt();
+      ownerPollHighWaterUpdatedAtId = maxOwnerChange.getId();
+    }
     entityPollHighWaterId =
         getOrDefault(
             SessionUtils.getWithoutCommit(
@@ -120,7 +125,10 @@ public class JcasbinChangePoller implements AutoCloseable {
   @VisibleForTesting
   void pollChanges() {
     try {
-      LOG.debug("Polling for owner changes after id {}", ownerPollHighWaterId);
+      LOG.debug(
+          "Polling for owner changes after (updated_at={}, id={})",
+          ownerPollHighWaterUpdatedAt,
+          ownerPollHighWaterUpdatedAtId);
       pollOwnerChanges();
     } catch (Exception e) {
       if (handleInterruptIfAny(e, "Owner change poll")) {
@@ -163,15 +171,16 @@ public class JcasbinChangePoller implements AutoCloseable 
{
   }
 
   /**
-   * Drains owner-change rows past {@link #ownerPollHighWaterId} and 
invalidates the affected {@code
-   * ownerRelCache} entries. Each row carries {@code metadataObjectId}, so 
invalidation is a direct
-   * key removal — no name resolution needed.
+   * Drains owner-change rows past {@link #ownerPollHighWaterUpdatedAt}/{@link
+   * #ownerPollHighWaterUpdatedAtId} and invalidates the affected {@code 
ownerRelCache} entries.
+   * Each row carries {@code metadataObjectId}, so invalidation is a direct 
key removal — no name
+   * resolution needed.
    *
    * <p>The {@code synchronized} modifier is defensive. In production this 
method is only invoked
    * from the single-threaded scheduler started in {@link #start()}, and {@link
    * java.util.concurrent.ScheduledExecutorService#scheduleWithFixedDelay} 
guarantees that
-   * consecutive runs do not overlap. The cursor field {@link 
#ownerPollHighWaterId} is also {@code
-   * volatile}, and cache invalidations are now atomic at the cache layer via 
{@link
+   * consecutive runs do not overlap. The cursor fields are also {@code 
volatile}, and cache
+   * invalidations are now atomic at the cache layer via {@link
    * org.apache.gravitino.cache.GravitinoCache#runInvalidationBatch}. The 
keyword is kept so that
    * future callers — additional schedulers, ad-hoc invocations from tests or 
admin tooling — do not
    * silently introduce concurrent {@code "select changes → invalidate → 
advance cursor"} sequences.
@@ -180,12 +189,14 @@ public class JcasbinChangePoller implements AutoCloseable 
{
   private synchronized void pollOwnerChanges() {
     List<ChangedOwnerInfo> changes =
         SessionUtils.getWithoutCommit(
-            OwnerMetaMapper.class, m -> 
m.selectChangedOwners(ownerPollHighWaterId));
+            OwnerMetaMapper.class,
+            m -> m.selectChangedOwners(ownerPollHighWaterUpdatedAt, 
ownerPollHighWaterUpdatedAtId));
     if (changes.isEmpty()) {
       return;
     }
 
-    long[] maxSeenId = {ownerPollHighWaterId};
+    long[] maxSeenUpdatedAt = {ownerPollHighWaterUpdatedAt};
+    long[] maxSeenUpdatedAtId = {ownerPollHighWaterUpdatedAtId};
     // Hold the cache's exclusive invalidation lock for the whole batch so 
readers never observe
     // a half-applied state where some of this batch's entries have been 
evicted and others are
     // still hot.
@@ -193,12 +204,16 @@ public class JcasbinChangePoller implements AutoCloseable 
{
         () -> {
           for (ChangedOwnerInfo change : changes) {
             ownerRelCache.invalidate(change.getMetadataObjectId());
-            if (change.getId() > maxSeenId[0]) {
-              maxSeenId[0] = change.getId();
+            if (change.getUpdatedAt() > maxSeenUpdatedAt[0]
+                || (change.getUpdatedAt() == maxSeenUpdatedAt[0]
+                    && change.getId() > maxSeenUpdatedAtId[0])) {
+              maxSeenUpdatedAt[0] = change.getUpdatedAt();
+              maxSeenUpdatedAtId[0] = change.getId();
             }
           }
         });
-    ownerPollHighWaterId = maxSeenId[0];
+    ownerPollHighWaterUpdatedAt = maxSeenUpdatedAt[0];
+    ownerPollHighWaterUpdatedAtId = maxSeenUpdatedAtId[0];
   }
 
   /**
diff --git 
a/server-common/src/test/java/org/apache/gravitino/server/authorization/MockGravitinoAuthorizer.java
 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/MockGravitinoAuthorizer.java
index 51c55ed797..e83e4f4e41 100644
--- 
a/server-common/src/test/java/org/apache/gravitino/server/authorization/MockGravitinoAuthorizer.java
+++ 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/MockGravitinoAuthorizer.java
@@ -88,7 +88,10 @@ public class MockGravitinoAuthorizer implements 
GravitinoAuthorizer {
   }
 
   @Override
-  public boolean isSelf(Entity.EntityType type, NameIdentifier nameIdentifier) 
{
+  public boolean isSelf(
+      Entity.EntityType type,
+      NameIdentifier nameIdentifier,
+      AuthorizationRequestContext requestContext) {
     return true;
   }
 
diff --git 
a/server-common/src/test/java/org/apache/gravitino/server/authorization/TestPassThroughAuthorizer.java
 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/TestPassThroughAuthorizer.java
index e173491d6a..e6099a6c47 100644
--- 
a/server-common/src/test/java/org/apache/gravitino/server/authorization/TestPassThroughAuthorizer.java
+++ 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/TestPassThroughAuthorizer.java
@@ -62,7 +62,9 @@ public class TestPassThroughAuthorizer {
       Assertions.assertTrue(passThroughAuthorizer.isServiceAdmin());
       Assertions.assertTrue(
           passThroughAuthorizer.isMetalakeUser("metalake", new 
AuthorizationRequestContext()));
-      
Assertions.assertTrue(passThroughAuthorizer.isSelf(Entity.EntityType.USER, 
null));
+      Assertions.assertTrue(
+          passThroughAuthorizer.isSelf(
+              Entity.EntityType.USER, null, new 
AuthorizationRequestContext()));
       Assertions.assertTrue(
           passThroughAuthorizer.hasSetOwnerPermission(
               "metalake", "type", "fullName", new 
AuthorizationRequestContext()));
diff --git 
a/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizationLookups.java
 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizationLookups.java
index b41035c857..de0fa232cf 100644
--- 
a/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizationLookups.java
+++ 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizationLookups.java
@@ -18,6 +18,12 @@
  */
 package org.apache.gravitino.server.authorization.jcasbin;
 
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
 import java.util.Arrays;
 import java.util.Optional;
 import java.util.function.Function;
@@ -25,9 +31,12 @@ import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.MetadataObjects;
 import org.apache.gravitino.authorization.AuthorizationRequestContext;
 import org.apache.gravitino.cache.GravitinoCache;
+import org.apache.gravitino.storage.relational.mapper.OwnerMetaMapper;
 import org.apache.gravitino.storage.relational.po.auth.OwnerInfo;
+import org.apache.gravitino.storage.relational.utils.SessionUtils;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
 
 /** Tests for {@link JcasbinAuthorizationLookups}. */
 public class TestJcasbinAuthorizationLookups {
@@ -37,7 +46,7 @@ public class TestJcasbinAuthorizationLookups {
     MetadataObject table =
         MetadataObjects.of(Arrays.asList("cat1", "sch1", "tbl1"), 
MetadataObject.Type.TABLE);
     CountingCache<String, Long> metadataIdCache = new CountingCache<>(100L);
-    CountingCache<Long, Optional<OwnerInfo>> ownerRelCache = new 
CountingCache<>(Optional.empty());
+    CountingCache<Long, Optional<OwnerInfo>> ownerRelCache = new 
CountingCache<>();
     JcasbinAuthorizationLookups lookups =
         new JcasbinAuthorizationLookups(metadataIdCache, ownerRelCache);
     AuthorizationRequestContext requestContext = new 
AuthorizationRequestContext();
@@ -53,17 +62,59 @@ public class TestJcasbinAuthorizationLookups {
   }
 
   @Test
-  void testResolveOwnerIdUsesAtomicSharedCacheAndRequestDedup() {
+  void testResolveOwnerIdCachesPositiveOwnerInSharedCache() {
     CountingCache<String, Long> metadataIdCache = new CountingCache<>(100L);
-    CountingCache<Long, Optional<OwnerInfo>> ownerRelCache = new 
CountingCache<>(Optional.empty());
+    CountingCache<Long, Optional<OwnerInfo>> ownerRelCache = new 
CountingCache<>();
+    JcasbinAuthorizationLookups lookups =
+        new JcasbinAuthorizationLookups(metadataIdCache, ownerRelCache);
+    OwnerMetaMapper ownerMetaMapper = mock(OwnerMetaMapper.class);
+    OwnerInfo ownerInfo = new OwnerInfo(10L, "USER");
+    when(ownerMetaMapper.selectOwnerByMetadataObjectIdAndType(
+            100L, MetadataObject.Type.TABLE.name()))
+        .thenReturn(ownerInfo);
+
+    try (MockedStatic<SessionUtils> sessionUtils = 
mockStatic(SessionUtils.class)) {
+      sessionUtils
+          .when(() -> SessionUtils.getWithoutCommit(any(), any()))
+          .thenAnswer(
+              invocation -> {
+                Function<OwnerMetaMapper, OwnerInfo> function = 
invocation.getArgument(1);
+                return function.apply(ownerMetaMapper);
+              });
+
+      Assertions.assertEquals(
+          Optional.of(ownerInfo),
+          lookups.resolveOwnerId(
+              100L, MetadataObject.Type.TABLE, new 
AuthorizationRequestContext()));
+      Assertions.assertEquals(
+          Optional.of(ownerInfo),
+          lookups.resolveOwnerId(
+              100L, MetadataObject.Type.TABLE, new 
AuthorizationRequestContext()));
+    }
+
+    Assertions.assertEquals(2, ownerRelCache.getCount);
+    Assertions.assertEquals(0, ownerRelCache.getIfPresentCount);
+    Assertions.assertEquals(1, ownerRelCache.putCount);
+    verify(ownerMetaMapper)
+        .selectOwnerByMetadataObjectIdAndType(100L, 
MetadataObject.Type.TABLE.name());
+  }
+
+  @Test
+  void testResolveOwnerIdDoesNotCacheMissingOwnerInSharedCache() {
+    CountingCache<String, Long> metadataIdCache = new CountingCache<>(100L);
+    CountingCache<Long, Optional<OwnerInfo>> ownerRelCache = new 
CountingCache<>();
     JcasbinAuthorizationLookups lookups =
         new JcasbinAuthorizationLookups(metadataIdCache, ownerRelCache);
     AuthorizationRequestContext requestContext = new 
AuthorizationRequestContext();
 
-    Assertions.assertFalse(
-        lookups.resolveOwnerId(100L, MetadataObject.Type.TABLE, 
requestContext).isPresent());
-    Assertions.assertFalse(
-        lookups.resolveOwnerId(100L, MetadataObject.Type.TABLE, 
requestContext).isPresent());
+    try (MockedStatic<SessionUtils> sessionUtils = 
mockStatic(SessionUtils.class)) {
+      sessionUtils.when(() -> SessionUtils.getWithoutCommit(any(), 
any())).thenReturn(null);
+
+      Assertions.assertFalse(
+          lookups.resolveOwnerId(100L, MetadataObject.Type.TABLE, 
requestContext).isPresent());
+      Assertions.assertFalse(
+          lookups.resolveOwnerId(100L, MetadataObject.Type.TABLE, 
requestContext).isPresent());
+    }
 
     Assertions.assertEquals(1, ownerRelCache.getCount);
     Assertions.assertEquals(0, ownerRelCache.getIfPresentCount);
@@ -72,10 +123,15 @@ public class TestJcasbinAuthorizationLookups {
 
   private static class CountingCache<K, V> implements GravitinoCache<K, V> {
     private final V value;
+    private Optional<V> cachedValue = Optional.empty();
     private int getCount;
     private int getIfPresentCount;
     private int putCount;
 
+    private CountingCache() {
+      this.value = null;
+    }
+
     private CountingCache(V value) {
       this.value = value;
     }
@@ -83,18 +139,29 @@ public class TestJcasbinAuthorizationLookups {
     @Override
     public Optional<V> getIfPresent(K key) {
       getIfPresentCount++;
-      return Optional.empty();
+      return cachedValue;
     }
 
     @Override
     public V get(K key, Function<K, V> loader) {
       getCount++;
-      return value;
+      if (cachedValue.isPresent()) {
+        return cachedValue.get();
+      }
+      if (value != null) {
+        cachedValue = Optional.of(value);
+        return value;
+      }
+      V loaded = loader.apply(key);
+      putCount++;
+      cachedValue = Optional.of(loaded);
+      return loaded;
     }
 
     @Override
     public void put(K key, V value) {
       putCount++;
+      cachedValue = Optional.of(value);
     }
 
     @Override
diff --git 
a/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizer.java
 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizer.java
index dba8697088..4b3292027f 100644
--- 
a/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizer.java
+++ 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizer.java
@@ -24,7 +24,9 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyList;
 import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.mock;
@@ -78,6 +80,7 @@ import 
org.apache.gravitino.storage.relational.mapper.RoleMetaMapper;
 import org.apache.gravitino.storage.relational.mapper.UserMetaMapper;
 import org.apache.gravitino.storage.relational.po.RolePO;
 import org.apache.gravitino.storage.relational.po.SecurableObjectPO;
+import org.apache.gravitino.storage.relational.po.auth.AuthPrefetchRow;
 import org.apache.gravitino.storage.relational.po.auth.GroupUpdatedAt;
 import org.apache.gravitino.storage.relational.po.auth.OwnerInfo;
 import org.apache.gravitino.storage.relational.po.auth.RoleUpdatedAt;
@@ -96,6 +99,7 @@ import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.MockedStatic;
+import org.mockito.Mockito;
 
 /** Test of {@link JcasbinAuthorizer} */
 public class TestJcasbinAuthorizer {
@@ -175,8 +179,9 @@ public class TestJcasbinAuthorizer {
     OwnerMetaService ownerMetaService = mock(OwnerMetaService.class);
     ownerMetaServiceMockedStatic = mockStatic(OwnerMetaService.class);
     
ownerMetaServiceMockedStatic.when(OwnerMetaService::getInstance).thenReturn(ownerMetaService);
-    when(ownerMetaMapper.selectMaxChangeId()).thenReturn(0L);
-    
when(ownerMetaMapper.selectChangedOwners(anyLong())).thenReturn(Collections.emptyList());
+    when(ownerMetaMapper.selectMaxChangedOwner()).thenReturn(null);
+    when(ownerMetaMapper.selectChangedOwners(anyLong(), anyLong()))
+        .thenReturn(Collections.emptyList());
     when(entityChangeLogMapper.selectMaxChangeId()).thenReturn(0L);
     when(entityChangeLogMapper.selectEntityChanges(anyLong(), anyInt()))
         .thenReturn(Collections.emptyList());
@@ -210,6 +215,51 @@ public class TestJcasbinAuthorizer {
     when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME)))
         .thenReturn(new UserUpdatedAt(USER_ID, 1000L));
 
+    // Fat-JOIN variant used by the cache-warm path: assemble user + groups + 
direct user roles
+    // + group-inherited roles + role versions from the existing per-subject 
mocks. Lets tests
+    // continue stubbing at the per-subject granularity.
+    when(userMetaMapper.batchGetAuthSubjectsForUser(anyString(), anyString(), 
anyList()))
+        .thenAnswer(
+            invocation -> {
+              String mlk = invocation.getArgument(0);
+              String uname = invocation.getArgument(1);
+              List<String> gNames = invocation.getArgument(2);
+              List<AuthPrefetchRow> rows = new ArrayList<>();
+              UserUpdatedAt u = userMetaMapper.getUserUpdatedAt(mlk, uname);
+              if (u != null) {
+                rows.add(AuthPrefetchRow.forUser(u.getUserId(), uname, 
u.getUpdatedAt()));
+                List<RolePO> directRoles = 
roleMetaMapper.listRolesByUserId(u.getUserId());
+                if (directRoles != null) {
+                  for (RolePO rp : directRoles) {
+                    RoleUpdatedAt rv = mockedRoleVersions.get(rp.getRoleId());
+                    long roleUpdatedAt = rv != null ? rv.getUpdatedAt() : 0L;
+                    rows.add(
+                        AuthPrefetchRow.forUserRole(
+                            rp.getRoleId(), rp.getRoleName(), roleUpdatedAt, 
u.getUserId()));
+                  }
+                }
+              }
+              if (gNames != null) {
+                for (String gn : gNames) {
+                  GroupUpdatedAt g = groupMetaMapper.getGroupUpdatedAt(mlk, 
gn);
+                  if (g != null) {
+                    rows.add(AuthPrefetchRow.forGroup(g.getGroupId(), gn, 
g.getUpdatedAt()));
+                    List<RolePO> groupRoles = 
roleMetaMapper.listRolesByGroupId(g.getGroupId());
+                    if (groupRoles != null) {
+                      for (RolePO rp : groupRoles) {
+                        RoleUpdatedAt rv = 
mockedRoleVersions.get(rp.getRoleId());
+                        long roleUpdatedAt = rv != null ? rv.getUpdatedAt() : 
0L;
+                        rows.add(
+                            AuthPrefetchRow.forGroupRole(
+                                rp.getRoleId(), rp.getRoleName(), 
roleUpdatedAt, g.getGroupId()));
+                      }
+                    }
+                  }
+                }
+              }
+              return rows;
+            });
+
     // Default: no roles assigned initially
     
when(roleMetaMapper.listRolesByUserId(eq(USER_ID))).thenReturn(ImmutableList.of());
     // Default answer pulls versions from mockedRoleVersions, populated by 
mockRoleInStore.
@@ -430,6 +480,79 @@ public class TestJcasbinAuthorizer {
     verify(roleMetaMapper).listRolesByUserId(eq(recreatedUserId));
   }
 
+  @Test
+  public void testVersionCheckEvictsPoliciesOfRolesMissingFromDb() throws 
Exception {
+    // Regression test for the cross-instance role-delete invalidation gap. The
+    // happy path is already handled by the fat-JOIN inside 
prefetchUserAndGroupInfo,
+    // which excludes soft-/hard-deleted roles via "role_meta.deleted_at = 0" 
and
+    // re-primes userRoleCache before the next loadUserRoles call. This test 
covers
+    // the defence-in-depth tier: if versionCheckAndLoadRoles is ever invoked 
with
+    // a roleId whose version probe row is missing (e.g. cache window race, 
future
+    // code path bypassing the fat-JOIN), the fix must still clear that role's
+    // p-rows from both enforcers and evict its loadedRoles entry so that any
+    // residual user → deleted-role g-row grants nothing on subsequent 
enforce()s.
+    makeCompletableFutureUseCurrentThread(jcasbinAuthorizer);
+    Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
+
+    // 1. Authorize once via the normal flow to populate loadedRoles + 
enforcer p-rows
+    //    for allowRole.
+    RoleEntity allowRole =
+        mockRoleInStore(ALLOW_ROLE_ID, "allowRole", 
ImmutableList.of(getAllowSecurableObject()));
+    long userVersion = nextUserVersion();
+    when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME)))
+        .thenReturn(new UserUpdatedAt(USER_ID, userVersion));
+    when(roleMetaMapper.listRolesByUserId(eq(USER_ID)))
+        .thenReturn(ImmutableList.of(buildRolePO(ALLOW_ROLE_ID, 
allowRole.name())));
+    assertTrue(doAuthorize(currentPrincipal));
+
+    // Sanity: loadedRoles now has an entry for ALLOW_ROLE_ID and the 
allowEnforcer
+    // contains a p-row whose subject (column 0) equals the role id.
+    Assertions.assertTrue(
+        
getLoadedRolesCache(jcasbinAuthorizer).getIfPresent(ALLOW_ROLE_ID).isPresent(),
+        "loadedRoles must be primed before the test");
+    Enforcer allowEnforcer = getAllowEnforcer(jcasbinAuthorizer);
+    Assertions.assertFalse(
+        allowEnforcer.getFilteredPolicy(0, 
String.valueOf(ALLOW_ROLE_ID)).isEmpty(),
+        "allowEnforcer must hold p-rows for allowRole before the test");
+
+    // 2. Simulate the bug-trigger: batchGetRoleUpdatedAt returns NO row for 
the role
+    //    (i.e. the role row is gone from role_meta), even though something is 
still
+    //    asking us to version-check it.
+    
when(roleMetaMapper.batchGetRoleUpdatedAt(any())).thenReturn(ImmutableList.of());
+
+    // Invoke versionCheckAndLoadRoles directly with the "deleted" role id and 
a
+    // fresh AuthorizationRequestContext that has NO prefetched role versions, 
so
+    // the method falls through to the batch probe and observes the empty 
result.
+    AuthorizationRequestContext freshCtx = new AuthorizationRequestContext();
+    invokeVersionCheckAndLoadRoles(
+        jcasbinAuthorizer, METALAKE, ImmutableList.of(ALLOW_ROLE_ID), 
freshCtx);
+
+    // 3. The fix must have cleared the role's p-rows and evicted loadedRoles.
+    Assertions.assertTrue(
+        allowEnforcer.getFilteredPolicy(0, 
String.valueOf(ALLOW_ROLE_ID)).isEmpty(),
+        "allowEnforcer p-rows for the deleted role must be cleared");
+    Assertions.assertFalse(
+        
getLoadedRolesCache(jcasbinAuthorizer).getIfPresent(ALLOW_ROLE_ID).isPresent(),
+        "loadedRoles entry for the deleted role must be evicted");
+  }
+
+  /** Reflectively invoke the private versionCheckAndLoadRoles. */
+  private static void invokeVersionCheckAndLoadRoles(
+      JcasbinAuthorizer authorizer,
+      String metalake,
+      List<Long> roleIds,
+      AuthorizationRequestContext requestContext)
+      throws Exception {
+    Method m =
+        JcasbinAuthorizer.class.getDeclaredMethod(
+            "versionCheckAndLoadRoles",
+            String.class,
+            List.class,
+            AuthorizationRequestContext.class);
+    m.setAccessible(true);
+    m.invoke(authorizer, metalake, roleIds, requestContext);
+  }
+
   @Test
   public void testAuthorizeByOwner() throws Exception {
     Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
@@ -462,6 +585,30 @@ public class TestJcasbinAuthorizer {
     assertFalse(doAuthorizeOwner(currentPrincipal));
   }
 
+  @Test
+  public void testPrefetchRunsAfterOwnerUserInfoLookup() throws Exception {
+    Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
+    RoleEntity allowRole =
+        mockRoleInStore(ALLOW_ROLE_ID, "allowRole", 
ImmutableList.of(getAllowSecurableObject()));
+    mockDirectUserRoles(allowRole);
+
+    when(ownerMetaMapper.selectOwnerByMetadataObjectIdAndType(eq(CATALOG_ID), 
eq("CATALOG")))
+        .thenReturn(new OwnerInfo(USER_ID + 1L, "USER"));
+    getOwnerRelCache(jcasbinAuthorizer).invalidateAll();
+
+    AuthorizationRequestContext requestContext = new 
AuthorizationRequestContext();
+    MetadataObject catalog = MetadataObjects.of(null, "testCatalog", 
MetadataObject.Type.CATALOG);
+
+    assertFalse(jcasbinAuthorizer.isOwner(currentPrincipal, METALAKE, catalog, 
requestContext));
+    Mockito.clearInvocations(userMetaMapper, roleMetaMapper);
+
+    assertTrue(
+        jcasbinAuthorizer.authorize(
+            currentPrincipal, METALAKE, catalog, USE_CATALOG, requestContext));
+    verify(userMetaMapper).batchGetAuthSubjectsForUser(eq(METALAKE), 
eq(USERNAME), anyList());
+    verify(roleMetaMapper, Mockito.never()).batchGetRoleUpdatedAt(any());
+  }
+
   @Test
   public void testAuthorizeByGroupOwner() throws Exception {
     // Set up a UserPrincipal whose groups include GROUP_NAME
@@ -613,11 +760,67 @@ public class TestJcasbinAuthorizer {
     mockGroupWithRoles(GROUP_NAME, ImmutableList.of(groupRoleId), 
ImmutableList.of(groupRoleName));
 
     // isSelf should return true -- role is assigned to user's group
-    assertTrue(jcasbinAuthorizer.isSelf(Entity.EntityType.ROLE, roleIdent));
+    assertTrue(
+        jcasbinAuthorizer.isSelf(
+            Entity.EntityType.ROLE, roleIdent, new 
AuthorizationRequestContext()));
 
     // A principal with no groups should fail
     setCurrentPrincipalWithGroup(null);
-    assertFalse(jcasbinAuthorizer.isSelf(Entity.EntityType.ROLE, roleIdent));
+    assertFalse(
+        jcasbinAuthorizer.isSelf(
+            Entity.EntityType.ROLE, roleIdent, new 
AuthorizationRequestContext()));
+
+    restoreDefaultPrincipal();
+  }
+
+  @Test
+  public void testIsSelfRoleReusesCacheAcrossCalls() throws Exception {
+    // Acceptance criterion for #11088: repeated isSelf(ROLE) calls in the 
same logical request
+    // must not re-issue the role-list DB queries (listRolesByUserId / 
listRolesByGroupId).
+    // The version-validated userRoleCache / groupRoleCache are process-wide, 
so the second call
+    // hits cache even though each isSelf creates a fresh 
AuthorizationRequestContext.
+    //
+    // Use CATALOG_ID so the role id matches the catch-all 
MetadataIdConverter.getID mock.
+    Long directRoleId = CATALOG_ID;
+    String directRoleName = "selfDedupRole";
+    NameIdentifier roleIdent = NameIdentifierUtil.ofRole(METALAKE, 
directRoleName);
+
+    // Direct user-role assignment via the version-validated cache path.
+    mockUserRoles(directRoleId, directRoleName);
+
+    // Use a fresh authorizer + principal to ensure the userRoleCache starts 
cold.
+    setCurrentPrincipalWithGroup(null);
+    Mockito.clearInvocations(roleMetaMapper);
+
+    // 1st call: miss → listRolesByUserId; 2nd call: cache hit → no extra 
listRolesByUserId.
+    AuthorizationRequestContext ctx1 = new AuthorizationRequestContext();
+    AuthorizationRequestContext ctx2 = new AuthorizationRequestContext();
+    assertTrue(jcasbinAuthorizer.isSelf(Entity.EntityType.ROLE, roleIdent, 
ctx1));
+    assertTrue(jcasbinAuthorizer.isSelf(Entity.EntityType.ROLE, roleIdent, 
ctx2));
+
+    Mockito.verify(roleMetaMapper, 
Mockito.times(1)).listRolesByUserId(eq(USER_ID));
+
+    restoreDefaultPrincipal();
+  }
+
+  @Test
+  public void testIsSelfRoleDoesNotCallListEntitiesByRelation() throws 
Exception {
+    // #11088: isSelf(ROLE) must not bypass the cache by going straight to
+    // entityStore.relationOperations().listEntitiesByRelation(ROLE_USER_REL, 
...).
+    Long directRoleId = CATALOG_ID;
+    String directRoleName = "noBypassRole";
+    NameIdentifier roleIdent = NameIdentifierUtil.ofRole(METALAKE, 
directRoleName);
+
+    mockUserRoles(directRoleId, directRoleName);
+    setCurrentPrincipalWithGroup(null);
+    Mockito.clearInvocations(supportsRelationOperations);
+
+    assertTrue(
+        jcasbinAuthorizer.isSelf(
+            Entity.EntityType.ROLE, roleIdent, new 
AuthorizationRequestContext()));
+
+    Mockito.verify(supportsRelationOperations, Mockito.never())
+        .listEntitiesByRelation(any(), any(), any());
 
     restoreDefaultPrincipal();
   }
@@ -773,6 +976,63 @@ public class TestJcasbinAuthorizer {
     getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
   }
 
+  @Test
+  public void testUserRoleRelChangeInvalidatesUserRoleCache() throws Exception 
{
+    makeCompletableFutureUseCurrentThread(jcasbinAuthorizer);
+    getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+
+    UserPrincipal noGroupPrincipal = setCurrentPrincipalWithGroup(null);
+    long userVersion = nextUserVersion();
+    when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME)))
+        .thenReturn(new UserUpdatedAt(USER_ID, userVersion));
+    
when(roleMetaMapper.listRolesByUserId(eq(USER_ID))).thenReturn(ImmutableList.of());
+
+    assertFalse(doAuthorize(noGroupPrincipal));
+
+    Long grantedRoleId = 20L;
+    RoleEntity grantedRole =
+        mockRoleInStore(
+            grantedRoleId, "userRelGrantedRole", 
ImmutableList.of(getAllowSecurableObject()));
+    when(roleMetaMapper.listRolesByUserId(eq(USER_ID)))
+        .thenReturn(ImmutableList.of(buildRolePO(grantedRoleId, 
grantedRole.name())));
+
+    jcasbinAuthorizer.handleUserRoleRelChange(METALAKE, USERNAME);
+
+    assertTrue(doAuthorize(noGroupPrincipal));
+
+    restoreDefaultPrincipal();
+    getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+  }
+
+  @Test
+  public void testGroupRoleRelChangeInvalidatesGroupRoleCache() throws 
Exception {
+    makeCompletableFutureUseCurrentThread(jcasbinAuthorizer);
+    getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+
+    UserPrincipal groupPrincipal = setCurrentPrincipalWithGroup(GROUP_NAME);
+    mockNoDirectUserRoles();
+    long groupVersion = groupVersionCounter.incrementAndGet();
+    when(groupMetaMapper.getGroupUpdatedAt(eq(METALAKE), eq(GROUP_NAME)))
+        .thenReturn(new GroupUpdatedAt(GROUP_ID, groupVersion));
+    
when(roleMetaMapper.listRolesByGroupId(eq(GROUP_ID))).thenReturn(ImmutableList.of());
+
+    assertFalse(doAuthorize(groupPrincipal));
+
+    Long grantedRoleId = 21L;
+    RoleEntity grantedRole =
+        mockRoleInStore(
+            grantedRoleId, "groupRelGrantedRole", 
ImmutableList.of(getAllowSecurableObject()));
+    when(roleMetaMapper.listRolesByGroupId(eq(GROUP_ID)))
+        .thenReturn(ImmutableList.of(buildRolePO(grantedRoleId, 
grantedRole.name())));
+
+    jcasbinAuthorizer.handleGroupRoleRelChange(METALAKE, GROUP_NAME);
+
+    assertTrue(doAuthorize(groupPrincipal));
+
+    restoreDefaultPrincipal();
+    getLoadedRolesCache(jcasbinAuthorizer).invalidateAll();
+  }
+
   /**
    * When the user is removed from a group at the IdP level (e.g. Azure AD), 
the next JWT token
    * won't include that group. On the next request the group's roles should no 
longer be available.
@@ -1444,6 +1704,10 @@ public class TestJcasbinAuthorizer {
         .thenReturn(ImmutableList.of(buildRolePO(roleId, roleName)));
     when(roleMetaMapper.batchGetRoleUpdatedAt(any()))
         .thenReturn(ImmutableList.of(new RoleUpdatedAt(roleId, roleName, 
roleVersion)));
+    // Also register the role in mockedRoleVersions so the fat-JOIN test stub 
for
+    // batchGetAuthSubjectsForUser surfaces it; otherwise prefetch's 
role-version map would
+    // miss this role and downstream loadPolicyByRoleEntity would never run.
+    mockedRoleVersions.put(roleId, new RoleUpdatedAt(roleId, roleName, 
roleVersion));
     when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME)))
         .thenReturn(new UserUpdatedAt(USER_ID, nextUserVersion()));
   }
diff --git 
a/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinChangePoller.java
 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinChangePoller.java
index e436fdd9bf..9e9c248167 100644
--- 
a/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinChangePoller.java
+++ 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinChangePoller.java
@@ -98,7 +98,7 @@ public class TestJcasbinChangePoller {
     EntityChangeLogMapper entityChangeLogMapper = 
mock(EntityChangeLogMapper.class);
     OwnerMetaMapper ownerMetaMapper = mock(OwnerMetaMapper.class);
 
-    
when(ownerMetaMapper.selectChangedOwners(0L)).thenReturn(Collections.emptyList());
+    when(ownerMetaMapper.selectChangedOwners(0L, 
0L)).thenReturn(Collections.emptyList());
     when(entityChangeLogMapper.selectEntityChanges(0L, 500))
         .thenReturn(
             List.of(
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
 
b/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
index 02d6048c95..d59db13f58 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
@@ -313,7 +313,10 @@ public class TestGravitinoInterceptionService {
     }
 
     @Override
-    public boolean isSelf(Entity.EntityType type, NameIdentifier 
nameIdentifier) {
+    public boolean isSelf(
+        Entity.EntityType type,
+        NameIdentifier nameIdentifier,
+        AuthorizationRequestContext requestContext) {
       return true;
     }
 

Reply via email to