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 e96a2fb902 [#13605] fix(authz): Reload role policies cleared between 
checks of one authorization request (#13606)
e96a2fb902 is described below

commit e96a2fb9024106904a8700d3e6236f6077cf3a9f
Author: Qi Yu <[email protected]>
AuthorDate: Fri Oct 9 17:36:46 2026 +0800

    [#13605] fix(authz): Reload role policies cleared between checks of one 
authorization request (#13606)
    
    ### What changes were proposed in this pull request?
    
    Repair role policies cleared between policy evaluations of one
    authorization request:
    
    - Record a per-role generation when eviction or invalidation removes
    policies. An explicit privilege change also records a generation for an
    empty role, so its first new DENY is reloaded. Atomic policy replacement
    does not record an intermediate clear.
    - Capture the generation before loading roles, then retain the bound
    role ids and unreadable-role results on the request. Advance its
    generation monotonically across concurrent list workers.
    - Before an uncached allow, deny, narrowed check or `hasDenyPolicy`
    scan, check the generation under the policy read lock. Reload outside
    the lock when needed, then validate again.
    - After three reload attempts, or a reload failure, fail closed: allow
    is false; deny and `hasDenyPolicy` are true. Unreadable role entities
    during the initial load keep all policy evaluations of that request
    closed; a fresh request can recover.
    - Bound the clear-generation map at the role cache size; a pruning
    watermark prevents older requests from missing a pruned clear.
    - Coordinate the concurrent eviction test so each request completes one
    eviction round before the next starts.
    
    Earlier authorization decisions remain request-cached. Shared authorizer
    logic serves REST, list filtering, Iceberg and Lance without changing
    their transports.
    
    ### Why are the changes needed?
    
    Eviction or privilege invalidation can clear a role's policies while its
    user-role binding remains. A later check in the same request can then
    reject a granted privilege or miss a DENY. Reloading before later policy
    evaluations repairs this gap.
    
    Fix: #13605
    
    ### Does this PR introduce _any_ user-facing change?
    
    No API or configuration fields change. Requests that race with policy
    clearing reload the affected policies. When the reload budget is
    exhausted or role entities cannot be read, checks fail closed and log a
    WARN.
    
    Partial policy resolution preserves its existing per-object behavior.
    Reliable completeness tracking for `hasDenyPolicy`, including backoff
    expiry and concurrent partial/complete replacements, is tracked
    separately in #13691. Per-role single-flight loading is tracked in
    #13612.
    
    ### How was this patch tested?
    
    Tests cover mid-request ALLOW/DENY eviction, privilege changes including
    an initially empty role, narrowed checks, initial-load eviction and read
    failure, reload exceptions, dropped roles, reload-budget exhaustion for
    all three entry points, pruning, monotonic generation updates, and 1,000
    composite decisions racing one eviction round per request. Partial-load
    behavior remains covered explicitly.
    
    Validation on head `4899c6bd41` (Java 17, non-Docker unit tests):
    
    - Full `core`: 2,543 passed, 2 existing disabled tests.
    - Full `server-common`: 398 passed on the follow-up run; server
    authorization: 103 passed.
    - Scoped formatting checks passed. Independent mutation checks caught a
    removed initial-read guard (1 regression) and disabled clear detection
    (4 regressions); all 5 passed after restoring the head.
    
    The first combined run had one failure in the unchanged TLS rejection
    test: the client observed a connection reset instead of the expected SSL
    handshake exception. The isolated TLS class passed on both base and head
    (7 cases each), and the full server-common follow-up passed. The initial
    failure remains recorded; it was not reproduced on the baseline.
    Docker/integration tests and full Iceberg/Lance module suites were not
    run.
---
 .../authorization/AuthorizationRequestContext.java |  82 +++
 .../TestAuthorizationRequestContext.java           |  63 +++
 .../authorization/jcasbin/JcasbinAuthorizer.java   | 347 ++++++++++--
 .../jcasbin/TestJcasbinAuthorizer.java             | 622 +++++++++++++++++++++
 4 files changed, 1057 insertions(+), 57 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 76c4b2fef7..e504c359c4 100644
--- 
a/core/src/main/java/org/apache/gravitino/authorization/AuthorizationRequestContext.java
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/AuthorizationRequestContext.java
@@ -20,11 +20,17 @@
 package org.apache.gravitino.authorization;
 
 import java.security.Principal;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
+import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
 import java.util.function.Function;
 import lombok.AllArgsConstructor;
 import lombok.EqualsAndHashCode;
@@ -89,6 +95,22 @@ public class AuthorizationRequestContext {
 
   private volatile String originalAuthorizationExpression;
 
+  /**
+   * Ids of the roles bound to the caller when this request loaded role 
policies. The authorizer
+   * uses them to detect whether one of these roles lost its policies after 
the load.
+   */
+  private volatile List<Long> boundRoleIds = Collections.emptyList();
+
+  /**
+   * Authorizer-defined generation of the in-memory role policies this request 
last validated its
+   * bound roles against. A role cleared after this generation must be 
reloaded before the request
+   * evaluates it again.
+   */
+  private final AtomicLong rolePolicyGeneration = new AtomicLong();
+
+  /** Roles whose entities could not be read during this request's initial 
role load. */
+  private volatile Set<Long> unreadableRoleIds = Collections.emptySet();
+
   /**
    * The roles the caller has declared active for this request (role 
assumption). Read from the
    * current {@link UserPrincipal}; defaults to {@link ActiveRoles#all()} (no 
narrowing) when the
@@ -232,6 +254,66 @@ public class AuthorizationRequestContext {
     this.prefetchedRoleVersions = prefetchedRoleVersions;
   }
 
+  /**
+   * Returns the ids of the roles bound to the caller when this request loaded 
role policies.
+   *
+   * @return the bound role ids; empty when no role has been loaded yet
+   */
+  public List<Long> getBoundRoleIds() {
+    return boundRoleIds;
+  }
+
+  /**
+   * Records the ids of the roles bound to the caller by this request's role 
load.
+   *
+   * @param boundRoleIds the bound role ids; must not be {@code null}
+   */
+  public void setBoundRoleIds(List<Long> boundRoleIds) {
+    this.boundRoleIds =
+        Collections.unmodifiableList(
+            new ArrayList<>(Objects.requireNonNull(boundRoleIds, "boundRoleIds 
must not be null")));
+  }
+
+  /**
+   * Returns the role policy generation this request last validated its bound 
roles against.
+   *
+   * @return the role policy generation
+   */
+  public long getRolePolicyGeneration() {
+    return rolePolicyGeneration.get();
+  }
+
+  /**
+   * Advances the role policy generation this request validated its bound 
roles against. Concurrent
+   * workers cannot move the recorded generation backwards.
+   *
+   * @param rolePolicyGeneration the role policy generation
+   */
+  public void setRolePolicyGeneration(long rolePolicyGeneration) {
+    this.rolePolicyGeneration.accumulateAndGet(rolePolicyGeneration, 
Math::max);
+  }
+
+  /**
+   * Returns the roles whose entities could not be read during the initial 
role load.
+   *
+   * @return the unreadable role ids; empty when all role entities were 
readable
+   */
+  public Set<Long> getUnreadableRoleIds() {
+    return unreadableRoleIds;
+  }
+
+  /**
+   * Records unreadable roles during the initial load so every check of this 
request fails closed.
+   *
+   * @param unreadableRoleIds the unreadable role ids; must not be {@code null}
+   */
+  public void setUnreadableRoleIds(Set<Long> unreadableRoleIds) {
+    this.unreadableRoleIds =
+        Collections.unmodifiableSet(
+            new HashSet<>(
+                Objects.requireNonNull(unreadableRoleIds, "unreadableRoleIds 
must not be null")));
+  }
+
   /**
    * Returns the roles declared active for this request. Never {@code null}; 
defaults to {@link
    * ActiveRoles#all()}.
diff --git 
a/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationRequestContext.java
 
b/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationRequestContext.java
index 313d490eac..755e10e97d 100644
--- 
a/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationRequestContext.java
+++ 
b/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationRequestContext.java
@@ -23,8 +23,12 @@ import static 
org.junit.jupiter.api.Assertions.assertInstanceOf;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
+import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
 import java.util.Optional;
+import java.util.Set;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -344,4 +348,63 @@ public class TestAuthorizationRequestContext {
         PrincipalUtils.doAs(principal, () -> new 
AuthorizationRequestContext().getActiveRoles());
     assertEquals(named, seen);
   }
+
+  @Test
+  public void testRolePolicyGenerationDoesNotMoveBackwardsAcrossWorkers() 
throws Exception {
+    AuthorizationRequestContext context = new AuthorizationRequestContext();
+    CountDownLatch newerRecorded = new CountDownLatch(1);
+    Thread olderWorker =
+        new Thread(
+            () -> {
+              try {
+                if (newerRecorded.await(5, TimeUnit.SECONDS)) {
+                  context.setRolePolicyGeneration(100L);
+                }
+              } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+              }
+            });
+    Thread newerWorker =
+        new Thread(
+            () -> {
+              context.setRolePolicyGeneration(105L);
+              newerRecorded.countDown();
+            });
+    olderWorker.start();
+    newerWorker.start();
+    olderWorker.join(5000);
+    newerWorker.join(5000);
+    assertFalse(olderWorker.isAlive());
+    assertFalse(newerWorker.isAlive());
+    assertEquals(105L, context.getRolePolicyGeneration());
+  }
+
+  @Test
+  public void testUnreadableRoleIdsAreCopied() {
+    AuthorizationRequestContext context = new AuthorizationRequestContext();
+    assertTrue(context.getUnreadableRoleIds().isEmpty());
+    Set<Long> roleIds = new HashSet<>(Arrays.asList(1L, 2L));
+    context.setUnreadableRoleIds(roleIds);
+    roleIds.add(3L);
+    assertEquals(new HashSet<>(Arrays.asList(1L, 2L)), 
context.getUnreadableRoleIds());
+    assertThrows(UnsupportedOperationException.class, () -> 
context.getUnreadableRoleIds().add(4L));
+    assertThrows(NullPointerException.class, () -> 
context.setUnreadableRoleIds(null));
+  }
+
+  @Test
+  public void testBoundRoleIdsAndRolePolicyGeneration() {
+    AuthorizationRequestContext context = new AuthorizationRequestContext();
+    assertTrue(context.getBoundRoleIds().isEmpty());
+    assertEquals(0L, context.getRolePolicyGeneration());
+
+    List<Long> roleIds = new ArrayList<>(Arrays.asList(1L, 2L));
+    context.setBoundRoleIds(roleIds);
+    context.setRolePolicyGeneration(7L);
+    roleIds.add(3L);
+
+    assertEquals(Arrays.asList(1L, 2L), context.getBoundRoleIds());
+    assertThrows(UnsupportedOperationException.class, () -> 
context.getBoundRoleIds().add(4L));
+    assertThrows(NullPointerException.class, () -> 
context.setBoundRoleIds(null));
+    assertEquals(7L, context.getRolePolicyGeneration());
+  }
 }
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 06f17d4366..9e67e07389 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
@@ -34,9 +34,13 @@ import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
 import java.util.concurrent.locks.ReentrantReadWriteLock;
+import java.util.function.BooleanSupplier;
 import java.util.stream.Collectors;
+import javax.annotation.Nullable;
 import org.apache.commons.io.IOUtils;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.Configs;
@@ -57,6 +61,7 @@ import org.apache.gravitino.authorization.Privilege;
 import org.apache.gravitino.authorization.SecurableObject;
 import org.apache.gravitino.cache.CaffeineGravitinoCache;
 import org.apache.gravitino.cache.GravitinoCache;
+import org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.meta.RoleEntity;
 import org.apache.gravitino.server.authorization.MetadataIdConverter;
 import org.apache.gravitino.storage.relational.SupportsEntityChangeLog;
@@ -111,7 +116,9 @@ import org.slf4j.LoggerFactory;
  *
  * <p>JCasbin enforcer state ({@link #allowEnforcer}/{@link #denyEnforcer}) is 
kept in sync with
  * {@link #loadedRoles} via the removal listener inside {@link 
JcasbinLoadedRolesCache} — evicting a
- * role id also deletes that role's policies from both enforcers.
+ * role id also deletes that role's policies from both enforcers. A request 
that loaded the role
+ * before such an eviction reloads it before its next check; see {@link
+ * #evaluateWithLoadedRolePolicies}.
  */
 public class JcasbinAuthorizer implements GravitinoAuthorizer {
 
@@ -138,6 +145,13 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
    */
   private static final long PARTIAL_ROLE_LOAD_RETRY_MS = 10_000L;
 
+  /**
+   * How many times a single authorization check reloads the caller's roles 
when their policies were
+   * cleared after the request loaded them. Beyond this the check fails 
closed; see {@link
+   * #evaluateWithLoadedRolePolicies}.
+   */
+  private static final int MAX_ROLE_POLICY_RELOADS = 3;
+
   /**
    * Guards every access to the in-memory JCasbin enforcer state. Policy 
reload mutates that state
    * with a clear-then-add sequence, which must be atomic not only against 
other writers but also
@@ -153,6 +167,43 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
    */
   private final ReentrantReadWriteLock rolePolicyLock = new 
ReentrantReadWriteLock();
 
+  /**
+   * Source of role policy generations. Advanced under the write lock of 
{@link #rolePolicyLock}
+   * whenever a role's policies are cleared without being replaced in the same 
critical section.
+   */
+  private final AtomicLong rolePolicyGenerationCounter = new AtomicLong();
+
+  /**
+   * roleId -> generation of the most recent clear of that role's policies. A 
request records the
+   * generation before it loads its roles; a bound role whose clear generation 
is newer has lost
+   * policies the request relies on and must be reloaded before the request 
evaluates it again. The
+   * map is bounded by {@link #maxRoleClearGenerations}; see {@link 
#prunedRoleClearGeneration} for
+   * how pruned entries stay safe.
+   */
+  private final Map<Long, Long> roleClearGenerations = new 
ConcurrentHashMap<>();
+
+  /**
+   * Upper bound on {@link #roleClearGenerations} entries, set once to the 
role cache size in {@link
+   * #initialize()} before use, and read under the write lock of {@link 
#rolePolicyLock}.
+   */
+  private long maxRoleClearGenerations;
+
+  /**
+   * The newest generation removed from {@link #roleClearGenerations} by 
pruning. A request whose
+   * recorded generation is older can no longer prove that none of its roles 
was cleared, so it
+   * reloads its roles once before the next check. Written under the write 
lock of {@link
+   * #rolePolicyLock} and only ever increases.
+   */
+  private volatile long prunedRoleClearGeneration;
+
+  /**
+   * The role whose marker {@link #replaceRolePolicies} is removing, or {@code 
null}. Guarded by the
+   * write lock of {@link #rolePolicyLock}. The synchronous removal callback 
clears that role's old
+   * policies, but the replacement is installed in the same critical section, 
so no request can
+   * observe the role without policies and the clear is not recorded as a 
generation.
+   */
+  @Nullable private Long roleBeingReplaced;
+
   /** Jcasbin enforcer is used for metadata authorization. */
   private Enforcer allowEnforcer;
 
@@ -226,12 +277,13 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
             .get(Configs.GRAVITINO_AUTHORIZATION_CHANGE_POLL_INTERVAL_SECS);
 
     long ttlMs = TimeUnit.SECONDS.toMillis(cacheExpirationSecs);
+    maxRoleClearGenerations = roleCacheSize;
 
     // Initialize enforcers before caches that reference them in removal 
listeners
     allowEnforcer = new SyncedEnforcer(getModel("/jcasbin_model.conf"), new 
GravitinoAdapter());
-    allowInternalAuthorizer = new InternalAuthorizer(allowEnforcer, true);
+    allowInternalAuthorizer = new InternalAuthorizer(allowEnforcer, true, 
false);
     denyEnforcer = new SyncedEnforcer(getModel("/jcasbin_model.conf"), new 
GravitinoAdapter());
-    denyInternalAuthorizer = new InternalAuthorizer(denyEnforcer, false);
+    denyInternalAuthorizer = new InternalAuthorizer(denyEnforcer, false, true);
 
     // loadedRoles: roleId -> updated_at.
     // When evicted, we must clean up the corresponding JCasbin policies.
@@ -432,35 +484,39 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
 
     Set<String> privilegeNames = 
privileges.stream().map(Enum::name).collect(Collectors.toSet());
     String userIdStr = String.valueOf(userId);
-    rolePolicyLock.readLock().lock();
-    try {
-      // This is an existence query, not a per-object check: it answers "does 
any deny on these
-      // privileges exist for the user's roles, at any scope?" The standard 
enforce path needs a
-      // concrete metadataId, so reusing it would mean iterating every listed 
object and defeat the
-      // short-circuit. Filtering the deny enforcer's policies by role keeps 
the scan bounded by the
-      // user's role/policy count, never by the number of listed objects. The 
match is intentionally
-      // scope-agnostic (no metadataType filter): a parent-scope deny hides 
the whole subtree and an
-      // object-scope deny hides one object, and both must disable the 
short-circuit.
-      for (String roleId : denyEnforcer.getRolesForUser(userIdStr)) {
-        // getFilteredNamedPolicy returns every "p" row (p = sub, 
metadataType, metadataId, act,
-        // eft)
-        // whose field at POLICY_SUBJECT_FIELD_INDEX (sub) equals roleId, i.e. 
all rules carried by
-        // this role. denyEnforcer is a dedicated enforcer that is only ever 
loaded with privileges
-        // whose condition is DENY (see loadPolicyByRoleEntity), so every row 
here represents a deny
-        // regardless of its stored eft string. Each returned row is the list 
of those five fields,
-        // so we read field POLICY_ACTION_FIELD_INDEX (act) to compare the 
denied privilege.
-        for (List<String> policy :
-            denyEnforcer.getFilteredNamedPolicy("p", 
POLICY_SUBJECT_FIELD_INDEX, roleId)) {
-          if (policy.size() > POLICY_ACTION_FIELD_INDEX
-              && 
privilegeNames.contains(policy.get(POLICY_ACTION_FIELD_INDEX))) {
-            return true;
-          }
-        }
-      }
-      return false;
-    } finally {
-      rolePolicyLock.readLock().unlock();
-    }
+    // A missing answer means the user's roles kept losing their policies; 
report a deny so the
+    // caller falls back to per-object checks instead of short-circuiting on 
incomplete policies.
+    return evaluateWithLoadedRolePolicies(
+            metalake,
+            requestContext,
+            () -> {
+              // This is an existence query, not a per-object check: it 
answers "does any deny on
+              // these privileges exist for the user's roles, at any scope?" 
The standard enforce
+              // path needs a concrete metadataId, so reusing it would mean 
iterating every listed
+              // object and defeat the short-circuit. Filtering the deny 
enforcer's policies by role
+              // keeps the scan bounded by the user's role/policy count, never 
by the number of
+              // listed objects. The match is intentionally scope-agnostic (no 
metadataType filter):
+              // a parent-scope deny hides the whole subtree and an 
object-scope deny hides one
+              // object, and both must disable the short-circuit.
+              for (String roleId : denyEnforcer.getRolesForUser(userIdStr)) {
+                // getFilteredNamedPolicy returns every "p" row (p = sub, 
metadataType, metadataId,
+                // act, eft) whose field at POLICY_SUBJECT_FIELD_INDEX (sub) 
equals roleId, i.e. all
+                // rules carried by this role. denyEnforcer is a dedicated 
enforcer that is only
+                // ever loaded with privileges whose condition is DENY (see 
resolveRolePolicies), so
+                // every row here represents a deny regardless of its stored 
eft string. Each
+                // returned row is the list of those five fields, so we read 
field
+                // POLICY_ACTION_FIELD_INDEX (act) to compare the denied 
privilege.
+                for (List<String> policy :
+                    denyEnforcer.getFilteredNamedPolicy("p", 
POLICY_SUBJECT_FIELD_INDEX, roleId)) {
+                  if (policy.size() > POLICY_ACTION_FIELD_INDEX
+                      && 
privilegeNames.contains(policy.get(POLICY_ACTION_FIELD_INDEX))) {
+                    return true;
+                  }
+                }
+              }
+              return false;
+            })
+        .orElse(true);
   }
 
   @Override
@@ -707,7 +763,7 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
 
   @Override
   public void handleRolePrivilegeChange(Long roleId) {
-    invalidateRolePolicies(roleId);
+    invalidateRolePolicies(roleId, true);
   }
 
   @Override
@@ -814,9 +870,18 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
      */
     private final boolean narrowByActiveRoles;
 
-    public InternalAuthorizer(Enforcer enforcer, boolean narrowByActiveRoles) {
+    /**
+     * The fail-closed result returned when the caller's role policies cannot 
be kept loaded for the
+     * duration of a check: {@code false} for the allow authorizer and {@code 
true} for the deny
+     * authorizer, so an unstable policy state can only reject a request.
+     */
+    private final boolean unstablePolicyResult;
+
+    public InternalAuthorizer(
+        Enforcer enforcer, boolean narrowByActiveRoles, boolean 
unstablePolicyResult) {
       this.enforcer = enforcer;
       this.narrowByActiveRoles = narrowByActiveRoles;
+      this.unstablePolicyResult = unstablePolicyResult;
     }
 
     private boolean authorizeInternal(
@@ -936,20 +1001,24 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
       // against just those roles (enforceNarrowed). ALL or an absent header 
falls through to the
       // normal check over every role the caller holds.
       ActiveRoles activeRoles = requestContext.getActiveRoles();
-      boolean allowed;
-      rolePolicyLock.readLock().lock();
-      try {
-        if (narrowByActiveRoles && !activeRoles.isAll()) {
-          allowed =
-              enforceNarrowed(
-                  userId, metadataType, metadataIdStr, privilege, activeRoles, 
requestContext);
-        } else {
-          allowed =
-              enforcer.enforce(String.valueOf(userId), metadataType, 
metadataIdStr, privilege);
-        }
-      } finally {
-        rolePolicyLock.readLock().unlock();
-      }
+      boolean allowed =
+          evaluateWithLoadedRolePolicies(
+                  metalake,
+                  requestContext,
+                  () -> {
+                    if (narrowByActiveRoles && !activeRoles.isAll()) {
+                      return enforceNarrowed(
+                          userId,
+                          metadataType,
+                          metadataIdStr,
+                          privilege,
+                          activeRoles,
+                          requestContext);
+                    }
+                    return enforcer.enforce(
+                        String.valueOf(userId), metadataType, metadataIdStr, 
privilege);
+                  })
+              .orElse(unstablePolicyResult);
 
       if (narrowByActiveRoles && !allowed) {
         diagnoseDenial(userId, metadataType, metadataIdStr, privilege);
@@ -1190,6 +1259,10 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
       AuthorizationRequestContext requestContext) {
     requestContext.loadRole(
         () -> {
+          // Read the generation before any role is loaded, so a clear that 
races with this load is
+          // newer than the recorded generation and is caught by the first 
check that follows.
+          long rolePolicyGeneration = rolePolicyGenerationCounter.get();
+
           // Step 1a: version-validated user-direct roles via cache.
           List<Long> userDirectRoleIds = loadUserRoles(metalake, username, 
userId, userInfo);
 
@@ -1242,8 +1315,11 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
           List<Long> allRoleIds = new ArrayList<>(userDirectRoleIds);
           allRoleIds.addAll(groupInheritedRoleIds);
           if (!allRoleIds.isEmpty()) {
-            versionCheckAndLoadRoles(metalake, allRoleIds, requestContext);
+            requestContext.setUnreadableRoleIds(
+                versionCheckAndLoadRoles(metalake, allRoleIds, 
requestContext));
           }
+          requestContext.setBoundRoleIds(allRoleIds);
+          requestContext.setRolePolicyGeneration(rolePolicyGeneration);
         });
   }
 
@@ -1345,7 +1421,13 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
     return 
groups.stream().map(UserGroup::getGroupName).collect(Collectors.toList());
   }
 
-  private void versionCheckAndLoadRoles(
+  /**
+   * Reloads every role in {@code roleIds} whose loaded version is missing or 
older than the DB.
+   *
+   * @return ids of stale roles whose entity could not be read, so their 
policies were not reloaded.
+   *     A role that no longer exists is not included: having no policies is 
correct for it.
+   */
+  private Set<Long> versionCheckAndLoadRoles(
       String metalake, List<Long> roleIds, AuthorizationRequestContext 
requestContext) {
     List<Long> uniqueRoleIds = 
roleIds.stream().distinct().collect(Collectors.toList());
 
@@ -1396,9 +1478,10 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
     }
 
     if (staleRoleVersions.isEmpty()) {
-      return;
+      return Collections.emptySet();
     }
 
+    Set<Long> unreadableRoleIds = new HashSet<>();
     EntityStore entityStore = GravitinoEnv.getInstance().entityStore();
     List<NameIdentifier> roleIdents =
         staleRoleVersions.stream()
@@ -1425,8 +1508,11 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
                   NameIdentifierUtil.ofRole(metalake, rv.getRoleName()),
                   Entity.EntityType.ROLE,
                   RoleEntity.class));
+        } catch (NoSuchEntityException e) {
+          LOG.debug("Role {} was dropped before its policies could be loaded", 
rv.getRoleId(), e);
         } catch (Exception e) {
           LOG.warn("Failed to load role policies for roleId {}", 
rv.getRoleId(), e);
+          unreadableRoleIds.add(rv.getRoleId());
         }
       }
     }
@@ -1467,6 +1553,7 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
             PARTIAL_ROLE_LOAD_RETRY_MS);
       }
     }
+    return unreadableRoleIds;
   }
 
   /**
@@ -1495,7 +1582,12 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
       // bound grouping links. If a marker exists, its synchronous removal 
callback clears the old
       // policies. With no marker, rows can still remain from a previous 
partial load and must be
       // cleared explicitly.
-      loadedRoles.invalidate(roleId);
+      roleBeingReplaced = roleId;
+      try {
+        loadedRoles.invalidate(roleId);
+      } finally {
+        roleBeingReplaced = null;
+      }
       if (!latestLoadedAt.isPresent()) {
         clearRolePoliciesWithoutLock(roleId);
       }
@@ -1519,8 +1611,13 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
 
   /** Clears a role's policies and removes its loaded marker as one serialized 
operation. */
   private void invalidateRolePolicies(long roleId) {
+    invalidateRolePolicies(roleId, false);
+  }
+
+  private void invalidateRolePolicies(long roleId, boolean 
recordEmptyRoleChange) {
     rolePolicyLock.writeLock().lock();
     try {
+      Long previousClearGeneration = roleClearGenerations.get(roleId);
       // An explicit invalidation is stronger than the retry throttle. Clear 
it under the same lock
       // before removing the loaded marker so the removal callback cannot 
mistake the old partial
       // state for a newer load and skip policy cleanup.
@@ -1529,7 +1626,13 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
       loadedRoles.invalidate(roleId);
       if (!markerPresent) {
         // An incomplete load has policies but deliberately has no loadedRoles 
marker.
-        clearRolePoliciesWithoutLock(roleId);
+        clearRolePoliciesAndRecordGeneration(roleId);
+      }
+      // A privilege change can grant a previously empty role its first deny 
policy. Such a role
+      // has no p-row to remove, but in-flight requests must still reload 
before their next check.
+      if (recordEmptyRoleChange
+          && Objects.equals(previousClearGeneration, 
roleClearGenerations.get(roleId))) {
+        recordRoleClearGeneration(roleId);
       }
     } finally {
       rolePolicyLock.writeLock().unlock();
@@ -1554,16 +1657,146 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
             roleId);
         return;
       }
-      clearRolePoliciesWithoutLock(roleId);
+      if (Objects.equals(roleBeingReplaced, roleId)) {
+        clearRolePoliciesWithoutLock(roleId);
+      } else {
+        clearRolePoliciesAndRecordGeneration(roleId);
+      }
     } finally {
       rolePolicyLock.writeLock().unlock();
     }
   }
 
-  private void clearRolePoliciesWithoutLock(long roleId) {
+  private boolean clearRolePoliciesWithoutLock(long roleId) {
     String roleIdStr = String.valueOf(roleId);
-    allowEnforcer.removeFilteredPolicy(0, roleIdStr);
-    denyEnforcer.removeFilteredPolicy(0, roleIdStr);
+    boolean allowRemoved = allowEnforcer.removeFilteredPolicy(0, roleIdStr);
+    boolean denyRemoved = denyEnforcer.removeFilteredPolicy(0, roleIdStr);
+    return allowRemoved || denyRemoved;
+  }
+
+  /**
+   * Clears a role's policies that are not replaced in the same critical 
section, and records the
+   * clear so that in-flight requests bound to the role reload it before their 
next check. Only a
+   * clear that removed policies is recorded: clearing an already empty role 
changes nothing a
+   * request could have observed, and recording it would make a deleted role 
look unstable forever.
+   * Must be called under the write lock of {@link #rolePolicyLock}.
+   */
+  private void clearRolePoliciesAndRecordGeneration(long roleId) {
+    if (clearRolePoliciesWithoutLock(roleId)) {
+      recordRoleClearGeneration(roleId);
+    }
+  }
+
+  /**
+   * Records a new clear generation for a role, pruning the older half of the 
generations when the
+   * map outgrows {@link #maxRoleClearGenerations}. Must be called under the 
write lock of {@link
+   * #rolePolicyLock}, so that readers see the pruned entries and the raised 
{@link
+   * #prunedRoleClearGeneration} together.
+   */
+  private void recordRoleClearGeneration(long roleId) {
+    roleClearGenerations.put(roleId, 
rolePolicyGenerationCounter.incrementAndGet());
+    if (roleClearGenerations.size() <= maxRoleClearGenerations) {
+      return;
+    }
+    long[] generations =
+        
roleClearGenerations.values().stream().mapToLong(Long::longValue).sorted().toArray();
+    long cutoff = generations[generations.length / 2];
+    roleClearGenerations.values().removeIf(generation -> generation <= cutoff);
+    prunedRoleClearGeneration = cutoff;
+  }
+
+  /**
+   * Evaluates one authorization check after repairing role policies cleared 
since this request
+   * loaded its roles.
+   *
+   * <p>A request loads its roles once, but each check releases {@link 
#rolePolicyLock} before the
+   * next one starts. In between, a {@link #loadedRoles} TTL or size eviction, 
or a privilege change
+   * on one of the roles, may clear that role's policies while the user's 
grouping row stays. A
+   * later check would then evaluate without the role: it can deny what the 
role allows, or allow
+   * what the role denies. So before each evaluation this method checks, under 
the read lock,
+   * whether a bound role was cleared after the request's recorded generation. 
If so, it reloads the
+   * cleared roles outside the lock, the same way a request's role load does, 
and checks again.
+   *
+   * <p>Earlier decisions keep their existing request-scoped results. Each new 
policy evaluation
+   * runs inside one read-locked section without holding the lock across 
metadata or DB lookups.
+   * Partial role-policy resolution keeps its existing per-object behavior; 
reliable completeness
+   * tracking for the deny-existence scan is handled separately in issue 
#13691.
+   *
+   * @return the evaluation result, or empty when the request's roles kept 
being cleared or could
+   *     not be reloaded; the caller must then fail closed
+   */
+  private Optional<Boolean> evaluateWithLoadedRolePolicies(
+      String metalake, AuthorizationRequestContext requestContext, 
BooleanSupplier evaluation) {
+    if (!requestContext.getUnreadableRoleIds().isEmpty()) {
+      LOG.warn(
+          "Failed to read roles {} during the initial role load; failing 
closed",
+          requestContext.getUnreadableRoleIds());
+      return Optional.empty();
+    }
+    for (int reloads = 0; ; reloads++) {
+      rolePolicyLock.readLock().lock();
+      try {
+        if (!hasClearedBoundRole(requestContext)) {
+          return Optional.of(evaluation.getAsBoolean());
+        }
+      } finally {
+        rolePolicyLock.readLock().unlock();
+      }
+
+      if (reloads == MAX_ROLE_POLICY_RELOADS) {
+        LOG.warn(
+            "Policies of roles {} were cleared again after {} reloads within 
one authorization "
+                + "check; failing closed. Check for frequent privilege changes 
on these roles or a "
+                + "loaded role cache that is too small on this node.",
+            requestContext.getBoundRoleIds(),
+            MAX_ROLE_POLICY_RELOADS);
+        return Optional.empty();
+      }
+
+      long rolePolicyGeneration = rolePolicyGenerationCounter.get();
+      Set<Long> unreadableRoleIds;
+      try {
+        unreadableRoleIds =
+            versionCheckAndLoadRoles(metalake, 
requestContext.getBoundRoleIds(), requestContext);
+      } catch (RuntimeException e) {
+        LOG.warn(
+            "Failed to reload cleared role policies for roles {}; failing 
closed",
+            requestContext.getBoundRoleIds(),
+            e);
+        return Optional.empty();
+      }
+      // A role whose entity could not be read has lost its policies without a 
replacement. Its
+      // missing rows prove neither the absence of a deny nor of an allow, so 
the check cannot be
+      // evaluated. Partially loaded and dropped roles are evaluated as usual: 
their policy state is
+      // the same one any request would see.
+      if (!unreadableRoleIds.isEmpty()) {
+        LOG.warn(
+            "Failed to read roles {} to reload their cleared policies; failing 
closed",
+            unreadableRoleIds);
+        return Optional.empty();
+      }
+      requestContext.setRolePolicyGeneration(rolePolicyGeneration);
+    }
+  }
+
+  /**
+   * Returns whether a role bound by this request was cleared after the 
request's recorded
+   * generation. Must be called under {@link #rolePolicyLock}.
+   */
+  private boolean hasClearedBoundRole(AuthorizationRequestContext 
requestContext) {
+    long rolePolicyGeneration = requestContext.getRolePolicyGeneration();
+    List<Long> boundRoleIds = requestContext.getBoundRoleIds();
+    if (!boundRoleIds.isEmpty() && rolePolicyGeneration < 
prunedRoleClearGeneration) {
+      // A clear of one of these roles may have been pruned from 
roleClearGenerations.
+      return true;
+    }
+    for (Long roleId : boundRoleIds) {
+      Long clearedAt = roleClearGenerations.get(roleId);
+      if (clearedAt != null && clearedAt > rolePolicyGeneration) {
+        return true;
+      }
+    }
+    return false;
   }
 
   private void bindUserRoles(long userId, List<Long> roleIds) {
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 e2570a348c..3bd54a64d6 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
@@ -60,9 +60,12 @@ import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.ThreadLocalRandom;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.locks.LockSupport;
 import java.util.concurrent.locks.ReentrantReadWriteLock;
 import java.util.function.Function;
 import java.util.stream.Collectors;
@@ -88,6 +91,7 @@ import org.apache.gravitino.catalog.SemanticModelDispatcher;
 import org.apache.gravitino.connector.BaseCatalog;
 import org.apache.gravitino.connector.capability.Capability;
 import org.apache.gravitino.connector.capability.CapabilityResult;
+import org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.hook.SemanticModelHookDispatcher;
 import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.BaseMetalake;
@@ -402,6 +406,8 @@ public class TestJcasbinAuthorizer {
 
   @Test
   public void testIsMetalakeUserUsesUserInfoCache() {
+    // userMetaMapper is shared by every test, so only count the calls made by 
this one.
+    Mockito.clearInvocations(userMetaMapper);
     assertTrue(jcasbinAuthorizer.isMetalakeUser(METALAKE, new 
AuthorizationRequestContext()));
     verify(userMetaMapper).getUserUpdatedAt(METALAKE, USERNAME);
   }
@@ -937,6 +943,622 @@ public class TestJcasbinAuthorizer {
         });
   }
 
+  @Test
+  public void testAllowPoliciesEvictedMidRequestAreReloadedBeforeLaterCheck() 
throws Exception {
+    Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
+    RoleEntity allowRole =
+        mockRoleInStore(ALLOW_ROLE_ID, "allowRole", 
ImmutableList.of(getAllowSecurableObject()));
+    mockDirectUserRoles(allowRole);
+    AuthorizationRequestContext requestContext = new 
AuthorizationRequestContext();
+
+    // The first check of a composite expression loads the role but does not 
match its scope.
+    assertFalse(
+        jcasbinAuthorizer.authorize(
+            currentPrincipal, METALAKE, metalakeObject(), USE_CATALOG, 
requestContext));
+
+    // TTL expiry or size eviction runs the removal listener, which clears the 
role's p-rows but
+    // keeps the user's g-row. The request will not run its one-time role load 
again.
+    getLoadedRolesCache(jcasbinAuthorizer).invalidate(ALLOW_ROLE_ID);
+    assertTrue(
+        getAllowEnforcer(jcasbinAuthorizer)
+            .getFilteredPolicy(0, String.valueOf(ALLOW_ROLE_ID))
+            .isEmpty());
+
+    assertTrue(
+        jcasbinAuthorizer.authorize(
+            currentPrincipal, METALAKE, catalogObject(), USE_CATALOG, 
requestContext),
+        "a later check in the same request must see the evicted role's 
policies again");
+  }
+
+  @Test
+  public void testRolePrivilegeChangeMidRequestDoesNotDenyLaterCheck() throws 
Exception {
+    Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
+    RoleEntity allowRole =
+        mockRoleInStore(ALLOW_ROLE_ID, "allowRole", 
ImmutableList.of(getAllowSecurableObject()));
+    mockDirectUserRoles(allowRole);
+    AuthorizationRequestContext requestContext = new 
AuthorizationRequestContext();
+
+    assertFalse(
+        jcasbinAuthorizer.authorize(
+            currentPrincipal, METALAKE, metalakeObject(), USE_CATALOG, 
requestContext));
+
+    // A grant or revoke on the role elsewhere on this node clears its 
policies immediately.
+    jcasbinAuthorizer.handleRolePrivilegeChange(ALLOW_ROLE_ID);
+
+    assertTrue(
+        jcasbinAuthorizer.authorize(
+            currentPrincipal, METALAKE, catalogObject(), USE_CATALOG, 
requestContext));
+  }
+
+  @Test
+  public void testDenyPoliciesEvictedMidRequestStillDeny() throws Exception {
+    Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
+    RoleEntity allowRole =
+        mockRoleInStore(ALLOW_ROLE_ID, "allowRole", 
ImmutableList.of(getAllowSecurableObject()));
+    RoleEntity denyRole =
+        mockRoleInStore(DENY_ROLE_ID, "denyRole", 
ImmutableList.of(getDenySecurableObject()));
+    mockDirectUserRoles(allowRole, denyRole);
+    AuthorizationRequestContext requestContext = new 
AuthorizationRequestContext();
+
+    assertFalse(
+        jcasbinAuthorizer.authorize(
+            currentPrincipal, METALAKE, metalakeObject(), USE_CATALOG, 
requestContext));
+
+    // Evicting the deny role between two checks must not let the allow role 
win: that would turn a
+    // cache eviction into privilege escalation.
+    getLoadedRolesCache(jcasbinAuthorizer).invalidate(DENY_ROLE_ID);
+
+    assertTrue(
+        jcasbinAuthorizer.deny(
+            currentPrincipal, METALAKE, catalogObject(), USE_CATALOG, 
requestContext),
+        "the deny check must still see the evicted deny role");
+    assertFalse(
+        jcasbinAuthorizer.authorize(
+            currentPrincipal, METALAKE, catalogObject(), USE_CATALOG, 
requestContext),
+        "the allow check must still honor the evicted deny role");
+  }
+
+  @Test
+  public void testReloadReadFailureMustNotLoseDeny() throws Exception {
+    Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
+    String roleName = "unavailableDenyRole";
+    RoleEntity denyRole =
+        mockRoleInStore(DENY_ROLE_ID, roleName, 
ImmutableList.of(getDenySecurableObject()));
+    mockDirectUserRoles(denyRole);
+    AuthorizationRequestContext requestContext = new 
AuthorizationRequestContext();
+
+    assertFalse(
+        jcasbinAuthorizer.authorize(
+            currentPrincipal, METALAKE, metalakeObject(), USE_CATALOG, 
requestContext));
+    getLoadedRolesCache(jcasbinAuthorizer).invalidate(DENY_ROLE_ID);
+    NameIdentifier roleIdent = NameIdentifierUtil.ofRole(METALAKE, roleName);
+    when(entityStore.get(eq(roleIdent), eq(Entity.EntityType.ROLE), 
eq(RoleEntity.class)))
+        .thenThrow(new IllegalStateException("Role store temporarily 
unavailable"));
+
+    assertTrue(
+        jcasbinAuthorizer.deny(
+            currentPrincipal, METALAKE, catalogObject(), USE_CATALOG, 
requestContext),
+        "an unsuccessful reload must fail closed instead of treating missing 
p-rows as no deny");
+  }
+
+  @Test
+  public void testInitialRoleReadFailureMustNotLoseDeny() throws Exception {
+    Principal principal = PrincipalUtils.getCurrentPrincipal();
+    String roleName = "unavailableInitialDenyRole";
+    RoleEntity allowRole =
+        mockRoleInStore(ALLOW_ROLE_ID, "allowRole", 
ImmutableList.of(getAllowSecurableObject()));
+    RoleEntity denyRole =
+        mockRoleInStore(DENY_ROLE_ID, roleName, 
ImmutableList.of(getDenySecurableObject()));
+    mockDirectUserRoles(allowRole, denyRole);
+    when(entityStore.get(
+            eq(NameIdentifierUtil.ofRole(METALAKE, roleName)),
+            eq(Entity.EntityType.ROLE),
+            eq(RoleEntity.class)))
+        .thenThrow(new IllegalStateException("Role store temporarily 
unavailable"));
+
+    // Each entry point must preserve the failure from its own initial load, 
including when
+    // another readable role grants the privilege. Subsequent checks of that 
request stay closed.
+    AuthorizationRequestContext allowContext = new 
AuthorizationRequestContext();
+    assertFalse(
+        jcasbinAuthorizer.authorize(
+            principal, METALAKE, catalogObject(), USE_CATALOG, allowContext));
+    assertTrue(
+        jcasbinAuthorizer.deny(principal, METALAKE, catalogObject(), 
USE_CATALOG, allowContext));
+    assertTrue(
+        jcasbinAuthorizer.hasDenyPolicy(
+            principal, METALAKE, ImmutableSet.of(USE_CATALOG), allowContext));
+    assertTrue(
+        jcasbinAuthorizer.deny(
+            principal, METALAKE, catalogObject(), USE_CATALOG, new 
AuthorizationRequestContext()));
+    assertTrue(
+        jcasbinAuthorizer.hasDenyPolicy(
+            principal, METALAKE, ImmutableSet.of(USE_CATALOG), new 
AuthorizationRequestContext()));
+
+    Mockito.doReturn(denyRole)
+        .when(entityStore)
+        .get(
+            eq(NameIdentifierUtil.ofRole(METALAKE, roleName)),
+            eq(Entity.EntityType.ROLE),
+            eq(RoleEntity.class));
+    AuthorizationRequestContext recoveredContext = new 
AuthorizationRequestContext();
+    assertFalse(
+        jcasbinAuthorizer.hasDenyPolicy(
+            principal, METALAKE, ImmutableSet.of(USE_SCHEMA), 
recoveredContext));
+    assertTrue(recoveredContext.getUnreadableRoleIds().isEmpty());
+    assertTrue(
+        jcasbinAuthorizer.hasDenyPolicy(
+            principal, METALAKE, ImmutableSet.of(USE_SCHEMA), allowContext),
+        "the failed request stays closed even after another request 
successfully loads the role");
+  }
+
+  @Test
+  public void testReloadExceptionFailsClosed() throws Exception {
+    Principal principal = PrincipalUtils.getCurrentPrincipal();
+    RoleEntity role =
+        mockRoleInStore(ALLOW_ROLE_ID, "allowRole", 
ImmutableList.of(getAllowSecurableObject()));
+    mockDirectUserRoles(role);
+    AuthorizationRequestContext context = new AuthorizationRequestContext();
+    assertFalse(
+        jcasbinAuthorizer.authorize(principal, METALAKE, metalakeObject(), 
USE_CATALOG, context));
+    getLoadedRolesCache(jcasbinAuthorizer).invalidate(ALLOW_ROLE_ID);
+    // Force the reload to probe versions rather than use the request's 
fat-JOIN snapshot.
+    context.setPrefetchedRoleVersions(Collections.emptyMap());
+    when(roleMetaMapper.batchGetRoleUpdatedAt(any()))
+        .thenThrow(new IllegalStateException("Role version probe 
unavailable"));
+
+    assertFalse(
+        jcasbinAuthorizer.authorize(principal, METALAKE, catalogObject(), 
USE_CATALOG, context));
+    assertTrue(jcasbinAuthorizer.deny(principal, METALAKE, catalogObject(), 
USE_CATALOG, context));
+    assertTrue(
+        jcasbinAuthorizer.hasDenyPolicy(
+            principal, METALAKE, ImmutableSet.of(USE_CATALOG), context));
+  }
+
+  @Test
+  public void testNewDenyOnInitiallyEmptyRoleIsSeenMidRequest() throws 
Exception {
+    Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
+    Long roleId = 32L;
+    String roleName = "newDenyRole";
+    RoleEntity emptyRole = mockRoleInStore(roleId, roleName, 
ImmutableList.of());
+    mockDirectUserRoles(emptyRole);
+    AuthorizationRequestContext requestContext = new 
AuthorizationRequestContext();
+
+    assertFalse(
+        jcasbinAuthorizer.authorize(
+            currentPrincipal, METALAKE, metalakeObject(), USE_CATALOG, 
requestContext));
+
+    mockRoleInStore(
+        roleId,
+        roleName,
+        ImmutableList.of(
+            buildSecurableObject(
+                roleId, MetadataObject.Type.CATALOG, "testCatalog", 
USE_CATALOG, "DENY")));
+    jcasbinAuthorizer.handleRolePrivilegeChange(roleId);
+
+    assertTrue(
+        jcasbinAuthorizer.deny(
+            currentPrincipal, METALAKE, catalogObject(), USE_CATALOG, 
requestContext),
+        "a privilege change on an empty role must trigger a reload in the same 
request");
+  }
+
+  @Test
+  public void testPartiallyLoadedRoleDoesNotFailClosedAfterEviction() throws 
Exception {
+    Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
+    RoleEntity allowRole =
+        mockRoleInStore(ALLOW_ROLE_ID, "allowRole", 
ImmutableList.of(getAllowSecurableObject()));
+    // A role that still references a dropped catalog loads partially and 
never gets a loaded
+    // marker. It must not turn every mid-request eviction of another role 
into a denial.
+    long partialRoleId = 40L;
+    RoleEntity partialRole =
+        mockRoleInStore(
+            partialRoleId,
+            "partialRole",
+            ImmutableList.of(
+                buildSecurableObject(
+                    partialRoleId,
+                    MetadataObject.Type.CATALOG,
+                    "droppedCatalog",
+                    USE_CATALOG,
+                    "ALLOW")));
+    metadataIdConverterMockedStatic
+        .when(
+            () ->
+                MetadataIdConverter.getID(
+                    Mockito.argThat(
+                        object -> object != null && 
"droppedCatalog".equals(object.name())),
+                    eq(METALAKE)))
+        .thenReturn(Optional.empty());
+    try {
+      mockDirectUserRoles(allowRole, partialRole);
+      AuthorizationRequestContext requestContext = new 
AuthorizationRequestContext();
+
+      assertFalse(
+          jcasbinAuthorizer.authorize(
+              currentPrincipal, METALAKE, metalakeObject(), USE_CATALOG, 
requestContext));
+      
assertFalse(getLoadedRolesCache(jcasbinAuthorizer).getIfPresent(partialRoleId).isPresent());
+      getLoadedRolesCache(jcasbinAuthorizer).invalidate(ALLOW_ROLE_ID);
+
+      assertTrue(
+          jcasbinAuthorizer.authorize(
+              currentPrincipal, METALAKE, catalogObject(), USE_CATALOG, 
requestContext));
+    } finally {
+      metadataIdConverterMockedStatic
+          .when(() -> MetadataIdConverter.getID(any(), eq(METALAKE)))
+          .thenReturn(Optional.of(CATALOG_ID));
+    }
+  }
+
+  @Test
+  public void testRoleClearGenerationsArePrunedWithoutMissingClears() throws 
Exception {
+    Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
+    String roleName = "prunedGenerationRole";
+    RoleEntity allowRole =
+        mockRoleInStore(ALLOW_ROLE_ID, roleName, 
ImmutableList.of(getAllowSecurableObject()));
+    mockDirectUserRoles(allowRole);
+    Field maxField = 
JcasbinAuthorizer.class.getDeclaredField("maxRoleClearGenerations");
+    maxField.setAccessible(true);
+    maxField.setLong(jcasbinAuthorizer, 4L);
+    Mockito.clearInvocations(entityStore);
+    AuthorizationRequestContext requestContext = new 
AuthorizationRequestContext();
+
+    assertFalse(
+        jcasbinAuthorizer.authorize(
+            currentPrincipal, METALAKE, metalakeObject(), USE_CATALOG, 
requestContext));
+
+    // Clear the request's role first, then enough other roles to prune its 
generation away.
+    getLoadedRolesCache(jcasbinAuthorizer).invalidate(ALLOW_ROLE_ID);
+    for (long roleId = 100L; roleId < 110L; roleId++) {
+      jcasbinAuthorizer.handleRolePrivilegeChange(roleId);
+    }
+    Field generationsField = 
JcasbinAuthorizer.class.getDeclaredField("roleClearGenerations");
+    generationsField.setAccessible(true);
+    Map<?, ?> generations = (Map<?, ?>) 
generationsField.get(jcasbinAuthorizer);
+    assertTrue(generations.size() <= 4, "the generation map must stay 
bounded");
+    assertFalse(
+        generations.containsKey(ALLOW_ROLE_ID), "the role's own clear must 
have been pruned");
+
+    assertTrue(
+        jcasbinAuthorizer.authorize(
+            currentPrincipal, METALAKE, catalogObject(), USE_CATALOG, 
requestContext),
+        "a pruned clear must still make the request reload the role");
+    verify(entityStore, Mockito.times(2))
+        .get(
+            eq(NameIdentifierUtil.ofRole(METALAKE, roleName)),
+            eq(Entity.EntityType.ROLE),
+            eq(RoleEntity.class));
+  }
+
+  @Test
+  public void testHasDenyPolicyReloadsEvictedDenyPolicies() throws Exception {
+    Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
+    RoleEntity denyRole =
+        mockRoleInStore(DENY_ROLE_ID, "denyRole", 
ImmutableList.of(getDenySecurableObject()));
+    mockDirectUserRoles(denyRole);
+    AuthorizationRequestContext requestContext = new 
AuthorizationRequestContext();
+
+    assertTrue(
+        jcasbinAuthorizer.hasDenyPolicy(
+            currentPrincipal, METALAKE, ImmutableSet.of(USE_CATALOG), 
requestContext));
+
+    getLoadedRolesCache(jcasbinAuthorizer).invalidate(DENY_ROLE_ID);
+
+    assertTrue(
+        jcasbinAuthorizer.hasDenyPolicy(
+            currentPrincipal, METALAKE, ImmutableSet.of(USE_CATALOG), 
requestContext),
+        "an evicted deny role must not disable the list short-circuit guard");
+  }
+
+  @Test
+  public void testUnrelatedRoleEvictionDoesNotReloadRequestRoles() throws 
Exception {
+    Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
+    RoleEntity allowRole =
+        mockRoleInStore(
+            ALLOW_ROLE_ID, "unrelatedEvictionRole", 
ImmutableList.of(getAllowSecurableObject()));
+    mockDirectUserRoles(allowRole);
+    Mockito.clearInvocations(entityStore);
+    AuthorizationRequestContext requestContext = new 
AuthorizationRequestContext();
+
+    assertFalse(
+        jcasbinAuthorizer.authorize(
+            currentPrincipal, METALAKE, metalakeObject(), USE_CATALOG, 
requestContext));
+
+    // Evict a loaded role that this request does not hold.
+    long otherRoleId = 99L;
+    getAllowEnforcer(jcasbinAuthorizer)
+        .addPolicy(
+            String.valueOf(otherRoleId),
+            MetadataObject.Type.CATALOG.name(),
+            String.valueOf(CATALOG_ID),
+            USE_CATALOG.name(),
+            AuthConstants.ALLOW);
+    GravitinoCache<Long, Long> loadedRoles = 
getLoadedRolesCache(jcasbinAuthorizer);
+    loadedRoles.put(otherRoleId, 1L);
+    loadedRoles.invalidate(otherRoleId);
+
+    assertTrue(
+        jcasbinAuthorizer.authorize(
+            currentPrincipal, METALAKE, catalogObject(), USE_CATALOG, 
requestContext));
+    verify(entityStore, Mockito.times(1))
+        .get(
+            eq(NameIdentifierUtil.ofRole(METALAKE, "unrelatedEvictionRole")),
+            eq(Entity.EntityType.ROLE),
+            eq(RoleEntity.class));
+  }
+
+  @Test
+  public void testRepeatedEvictionsFailClosed() throws Exception {
+    Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
+    long otherRoleId = 31L;
+    String roleName = "pingRole";
+    String otherRoleName = "pongRole";
+    RoleEntity role =
+        getRoleEntity(ALLOW_ROLE_ID, roleName, 
ImmutableList.of(getAllowSecurableObject()));
+    RoleEntity otherRole =
+        getRoleEntity(otherRoleId, otherRoleName, 
ImmutableList.of(getAllowSecurableObject()));
+    mockedRoleVersions.put(
+        ALLOW_ROLE_ID, new RoleUpdatedAt(ALLOW_ROLE_ID, roleName, 
nextRoleVersion()));
+    mockedRoleVersions.put(
+        otherRoleId, new RoleUpdatedAt(otherRoleId, otherRoleName, 
nextRoleVersion()));
+    GravitinoCache<Long, Long> loadedRoles = 
getLoadedRolesCache(jcasbinAuthorizer);
+    AtomicReference<Boolean> armed = new AtomicReference<>(false);
+    // Reloading either role evicts the other one, so the request's roles 
never become stable.
+    doAnswer(
+            invocation -> {
+              if (armed.get()) {
+                loadedRoles.invalidate(otherRoleId);
+              }
+              return role;
+            })
+        .when(entityStore)
+        .get(
+            eq(NameIdentifierUtil.ofRole(METALAKE, roleName)),
+            eq(Entity.EntityType.ROLE),
+            eq(RoleEntity.class));
+    doAnswer(
+            invocation -> {
+              if (armed.get()) {
+                loadedRoles.invalidate(ALLOW_ROLE_ID);
+              }
+              return otherRole;
+            })
+        .when(entityStore)
+        .get(
+            eq(NameIdentifierUtil.ofRole(METALAKE, otherRoleName)),
+            eq(Entity.EntityType.ROLE),
+            eq(RoleEntity.class));
+    mockDirectUserRoles(role, otherRole);
+
+    AuthorizationRequestContext allowContext = new 
AuthorizationRequestContext();
+    assertFalse(
+        jcasbinAuthorizer.authorize(
+            currentPrincipal, METALAKE, metalakeObject(), USE_CATALOG, 
allowContext));
+    armed.set(true);
+    loadedRoles.invalidate(ALLOW_ROLE_ID);
+    assertFalse(
+        jcasbinAuthorizer.authorize(
+            currentPrincipal, METALAKE, catalogObject(), USE_CATALOG, 
allowContext),
+        "an allow check must fail closed when the request's policies never 
stabilize");
+
+    armed.set(false);
+    AuthorizationRequestContext denyContext = new 
AuthorizationRequestContext();
+    assertFalse(
+        jcasbinAuthorizer.authorize(
+            currentPrincipal, METALAKE, metalakeObject(), USE_CATALOG, 
denyContext));
+    armed.set(true);
+    loadedRoles.invalidate(ALLOW_ROLE_ID);
+    assertTrue(
+        jcasbinAuthorizer.deny(
+            currentPrincipal, METALAKE, catalogObject(), USE_CATALOG, 
denyContext),
+        "a deny check must fail closed when the request's policies never 
stabilize");
+    armed.set(false);
+    AuthorizationRequestContext scanContext = new 
AuthorizationRequestContext();
+    assertFalse(
+        jcasbinAuthorizer.hasDenyPolicy(
+            currentPrincipal, METALAKE, ImmutableSet.of(USE_CATALOG), 
scanContext));
+    armed.set(true);
+    loadedRoles.invalidate(ALLOW_ROLE_ID);
+    assertTrue(
+        jcasbinAuthorizer.hasDenyPolicy(
+            currentPrincipal, METALAKE, ImmutableSet.of(USE_CATALOG), 
scanContext),
+        "a deny scan must fail closed when the request's policies never 
stabilize");
+    armed.set(false);
+  }
+
+  @Test
+  public void testRoleDeletedMidRequestDeniesWithoutLooping() throws Exception 
{
+    Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
+    String roleName = "deletedMidRequestRole";
+    RoleEntity allowRole =
+        mockRoleInStore(ALLOW_ROLE_ID, roleName, 
ImmutableList.of(getAllowSecurableObject()));
+    mockDirectUserRoles(allowRole);
+    Mockito.clearInvocations(entityStore);
+    AuthorizationRequestContext requestContext = new 
AuthorizationRequestContext();
+
+    assertFalse(
+        jcasbinAuthorizer.authorize(
+            currentPrincipal, METALAKE, metalakeObject(), USE_CATALOG, 
requestContext));
+
+    NameIdentifier roleIdent = NameIdentifierUtil.ofRole(METALAKE, roleName);
+    when(entityStore.get(eq(roleIdent), eq(Entity.EntityType.ROLE), 
eq(RoleEntity.class)))
+        .thenThrow(new NoSuchEntityException("Role %s is dropped", roleName));
+    Mockito.clearInvocations(entityStore);
+    jcasbinAuthorizer.handleRolePrivilegeChange(ALLOW_ROLE_ID);
+
+    assertFalse(
+        jcasbinAuthorizer.authorize(
+            currentPrincipal, METALAKE, catalogObject(), USE_CATALOG, 
requestContext));
+    verify(entityStore, Mockito.times(1))
+        .get(eq(roleIdent), eq(Entity.EntityType.ROLE), eq(RoleEntity.class));
+  }
+
+  @Test
+  public void testDenialByEmptyRoleDoesNotReloadIt() throws Exception {
+    Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
+    RoleEntity emptyRole = mockRoleInStore(32L, "emptyRole", 
ImmutableList.of());
+    mockDirectUserRoles(emptyRole);
+    Mockito.clearInvocations(entityStore);
+    AuthorizationRequestContext requestContext = new 
AuthorizationRequestContext();
+
+    assertFalse(
+        jcasbinAuthorizer.authorize(
+            currentPrincipal, METALAKE, metalakeObject(), USE_CATALOG, 
requestContext));
+    assertFalse(
+        jcasbinAuthorizer.authorize(
+            currentPrincipal, METALAKE, catalogObject(), USE_CATALOG, 
requestContext));
+    assertFalse(
+        jcasbinAuthorizer.deny(
+            currentPrincipal, METALAKE, catalogObject(), USE_CATALOG, 
requestContext));
+
+    verify(entityStore, Mockito.times(1))
+        .get(
+            eq(NameIdentifierUtil.ofRole(METALAKE, "emptyRole")),
+            eq(Entity.EntityType.ROLE),
+            eq(RoleEntity.class));
+  }
+
+  @Test
+  public void testEvictionDuringRoleLoadIsDetected() throws Exception {
+    Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
+    RoleEntity allowRole =
+        mockRoleInStore(ALLOW_ROLE_ID, "allowRole", 
ImmutableList.of(getAllowSecurableObject()));
+    mockDirectUserRoles(allowRole);
+    assertTrue(doAuthorize(currentPrincipal));
+
+    // The next request loads a second role. While that load is in progress, 
the already loaded
+    // role is evicted; the eviction must not slip between the load and the 
first check.
+    long otherRoleId = 33L;
+    String otherRoleName = "roleLoadedDuringEviction";
+    RoleEntity otherRole = getRoleEntity(otherRoleId, otherRoleName, 
ImmutableList.of());
+    mockedRoleVersions.put(
+        otherRoleId, new RoleUpdatedAt(otherRoleId, otherRoleName, 
nextRoleVersion()));
+    GravitinoCache<Long, Long> loadedRoles = 
getLoadedRolesCache(jcasbinAuthorizer);
+    doAnswer(
+            invocation -> {
+              loadedRoles.invalidate(ALLOW_ROLE_ID);
+              return otherRole;
+            })
+        .when(entityStore)
+        .get(
+            eq(NameIdentifierUtil.ofRole(METALAKE, otherRoleName)),
+            eq(Entity.EntityType.ROLE),
+            eq(RoleEntity.class));
+    mockDirectUserRoles(allowRole, otherRole);
+
+    assertTrue(
+        jcasbinAuthorizer.authorize(
+            currentPrincipal,
+            METALAKE,
+            catalogObject(),
+            USE_CATALOG,
+            new AuthorizationRequestContext()));
+  }
+
+  @Test
+  public void testNarrowedAuthorizationReloadsEvictedPolicies() throws 
Exception {
+    Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
+    RoleEntity allowRole =
+        mockRoleInStore(ALLOW_ROLE_ID, "narrowedRole", 
ImmutableList.of(getAllowSecurableObject()));
+    mockDirectUserRoles(allowRole);
+    AuthorizationRequestContext requestContext = new 
AuthorizationRequestContext();
+    
requestContext.setActiveRoles(ActiveRoles.of(ImmutableList.of("narrowedRole")));
+
+    assertFalse(
+        jcasbinAuthorizer.authorize(
+            currentPrincipal, METALAKE, metalakeObject(), USE_CATALOG, 
requestContext));
+    getLoadedRolesCache(jcasbinAuthorizer).invalidate(ALLOW_ROLE_ID);
+
+    assertTrue(
+        jcasbinAuthorizer.authorize(
+            currentPrincipal, METALAKE, catalogObject(), USE_CATALOG, 
requestContext));
+  }
+
+  /** Verifies bounded cache eviction races preserve composite authorization 
decisions. */
+  @Test
+  public void testConcurrentEvictionNeverFlipsCompositeDecisions() throws 
Exception {
+    Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
+    RoleEntity allowRole =
+        mockRoleInStore(ALLOW_ROLE_ID, "allowRole", 
ImmutableList.of(getAllowSecurableObject()));
+    RoleEntity denyRole =
+        mockRoleInStore(DENY_ROLE_ID, "denyRole", 
ImmutableList.of(getDenySecurableObject()));
+    GravitinoCache<Long, Long> loadedRoles = 
getLoadedRolesCache(jcasbinAuthorizer);
+
+    // Requests run on this thread because the static mocks are thread-local. 
The evictor only
+    // touches the loaded-role cache, whose removal listener clears policies 
under the write lock,
+    // the same way a TTL or size eviction does. It evicts at most once per 
request, at a random
+    // point in it, so a request never sees more clears than a check may 
reload; a check that runs
+    // out of reloads fails closed by design, which is covered by 
testRepeatedEvictionsFailClosed.
+    // Each request waits for its eviction round to finish before the next 
request starts, so a
+    // delayed eviction cannot spill into another request and exceed the 
intended reload budget.
+    Semaphore evictionRequests = new Semaphore(0);
+    Semaphore completedEvictions = new Semaphore(0);
+    AtomicLong evictions = new AtomicLong();
+    Thread evictor =
+        new Thread(
+            () -> {
+              try {
+                while (!Thread.currentThread().isInterrupted()) {
+                  evictionRequests.acquire();
+                  
LockSupport.parkNanos(ThreadLocalRandom.current().nextLong(200_000L));
+                  loadedRoles.invalidate(ALLOW_ROLE_ID);
+                  loadedRoles.invalidate(DENY_ROLE_ID);
+                  evictions.incrementAndGet();
+                  completedEvictions.release();
+                }
+              } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+              }
+            });
+    evictor.setDaemon(true);
+    try {
+      evictor.start();
+
+      // Granted: only the allow role is held. Every composite decision must 
be allowed.
+      mockDirectUserRoles(allowRole);
+      for (int i = 0; i < 500; i++) {
+        AuthorizationRequestContext ctx = new AuthorizationRequestContext();
+        evictionRequests.release();
+        assertFalse(
+            jcasbinAuthorizer.authorize(
+                currentPrincipal, METALAKE, metalakeObject(), USE_CATALOG, 
ctx));
+        assertTrue(
+            jcasbinAuthorizer.authorize(
+                currentPrincipal, METALAKE, catalogObject(), USE_CATALOG, ctx),
+            "an eviction must not deny a granted check");
+        assertTrue(completedEvictions.tryAcquire(5, TimeUnit.SECONDS));
+      }
+
+      // Denied: the allow role and the deny role are both held. No decision 
may be allowed.
+      mockDirectUserRoles(allowRole, denyRole);
+      for (int i = 0; i < 500; i++) {
+        AuthorizationRequestContext ctx = new AuthorizationRequestContext();
+        evictionRequests.release();
+        assertFalse(
+            jcasbinAuthorizer.authorize(
+                currentPrincipal, METALAKE, metalakeObject(), USE_CATALOG, 
ctx));
+        assertFalse(
+            jcasbinAuthorizer.authorize(
+                    currentPrincipal, METALAKE, catalogObject(), USE_CATALOG, 
ctx)
+                && !jcasbinAuthorizer.deny(
+                    currentPrincipal, METALAKE, catalogObject(), USE_CATALOG, 
ctx),
+            "an eviction must not allow a denied check");
+        assertTrue(completedEvictions.tryAcquire(5, TimeUnit.SECONDS));
+      }
+    } finally {
+      evictor.interrupt();
+      evictor.join(5000L);
+    }
+    assertFalse(evictor.isAlive(), "the evictor must stop before the test 
exits");
+    assertEquals(1000L, evictions.get(), "each request must complete exactly 
one eviction round");
+  }
+
+  private static MetadataObject metalakeObject() {
+    return MetadataObjects.of(null, METALAKE, MetadataObject.Type.METALAKE);
+  }
+
+  private static MetadataObject catalogObject() {
+    return MetadataObjects.of(null, "testCatalog", 
MetadataObject.Type.CATALOG);
+  }
+
   /** Reflectively invoke the private versionCheckAndLoadRoles. */
   private static void invokeVersionCheckAndLoadRoles(
       JcasbinAuthorizer authorizer,

Reply via email to