jerryshao commented on code in PR #13606:
URL: https://github.com/apache/gravitino/pull/13606#discussion_r4215594650


##########
server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizer.java:
##########
@@ -1554,16 +1656,140 @@ private void clearRolePoliciesOnCacheRemoval(long 
roleId) {
             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 against role policies that are at least 
as complete as the
+   * ones the request loaded.
+   *
+   * <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 checks of the request stay valid because they ran against 
complete policies. This
+   * keeps each check's evaluation inside one read-locked section without 
holding the lock across
+   * metadata or DB lookups.
+   *
+   * @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) {
+    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, new ArrayList<>(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()) {

Review Comment:
   [Question] Should a role that reloads only *partially* also fail 
`hasDenyPolicy` closed?
   
   The comment here says partially loaded roles are evaluated as usual, and 
`testPartiallyLoadedRoleDoesNotFailClosedAfterEviction` pins that. For the 
per-object `enforce` paths I agree it is harmless: `resolveRolePolicies` only 
drops rows whose securable object did not resolve (lines 1856-1862), and a 
check on such an object already returns `false` at `authorizeObject` when 
`resolveMetadataId` is empty.
   
   `hasDenyPolicy` is different, because its scan is deliberately 
scope-agnostic (line 501-513): it answers "does any deny on these privileges 
exist for the user's roles, at any scope?". A partial reload that drops a DENY 
row for a currently unresolvable parent (e.g. a transient 
`metadataIdCache`/resolution miss, not only a dropped object) turns that answer 
from `true` to `false` and re-enables the parent-scope short-circuit at 
`MetadataAuthzHelper.java:342-349`, which skips per-object checks for the whole 
listing.
   
   This is pre-existing rather than introduced here - partial loads could 
already shrink the deny enforcer - so I am raising it as a question, not a 
blocker: is it in scope to also fail `hasDenyPolicy` closed when 
`replaceRolePolicies` installed an incomplete resolution for a bound role, or 
is that better handled with #13612?
   
   Verified by: read `resolveRolePolicies` (1846-1888), `replaceRolePolicies` 
(1571-1608), the `hasDenyPolicy` scan (489-519) and its single caller 
`MetadataAuthzHelper.java:342-349`.



##########
server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizer.java:
##########
@@ -1244,6 +1317,8 @@ private void loadRolePrivilege(
           if (!allRoleIds.isEmpty()) {
             versionCheckAndLoadRoles(metalake, allRoleIds, requestContext);
           }
+          requestContext.setBoundRoleIds(allRoleIds);

Review Comment:
   [Important] The request's own role load drops the fail-closed signal, so the 
lost-deny this PR fixes stays open for the first load.
   
   `versionCheckAndLoadRoles` now returns the roles whose entity could not be 
read, and `evaluateWithLoadedRolePolicies` fails the check closed on a 
non-empty set (lines 1423-1429, 1765-1769). But the call at line 1316 inside 
this `loadRole` lambda discards that return value, and then lines 1320-1321 
record `boundRoleIds` and the generation as if the load had succeeded.
   
   Concretely, for a role that is stale or not loaded on this node and whose 
entity read throws something other than `NoSuchEntityException` (line 
1510-1513): `bindUserRoles` has already added the user's g-row for it (lines 
1336/1347/1388/1399, which run before line 1316), no policies are installed, 
and no clear generation is recorded - so `hasClearedBoundRole` stays `false` 
for the whole request and every check evaluates as if the role were empty. If 
that role carried a DENY, `deny()` returns `false` and `hasDenyPolicy()` 
returns `false`, which re-enables the parent-scope list short-circuit at 
`MetadataAuthzHelper.java:342-349`. That is the same outcome 
`testReloadReadFailureMustNotLoseDeny` is written to prevent, just one step 
earlier in the request.
   
   Suggested fix: keep the returned set (e.g. on the context) and have 
`evaluateWithLoadedRolePolicies` treat a role in it as unresolved on the first 
check too - the existing `Optional.empty()` plumbing already handles the rest - 
or at minimum record a clear generation for those roles so each check retries 
and then fails closed.
   
   Verified by: read `loadRolePrivilege` (1255-1322), 
`versionCheckAndLoadRoles` (1429-1555) and the `bindUserRoles` call sites in 
`loadUserRoles`/`loadGroupRoles` on this head; confirmed 
`MetadataAuthzHelper.java:342-349` is the only caller of `hasDenyPolicy`.



##########
server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizer.java:
##########
@@ -1554,16 +1656,140 @@ private void clearRolePoliciesOnCacheRemoval(long 
roleId) {
             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 against role policies that are at least 
as complete as the
+   * ones the request loaded.
+   *
+   * <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 checks of the request stay valid because they ran against 
complete policies. This
+   * keeps each check's evaluation inside one read-locked section without 
holding the lock across
+   * metadata or DB lookups.
+   *
+   * @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) {
+    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, new ArrayList<>(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);

Review Comment:
   [Nit] This write is not monotonic, and one `AuthorizationRequestContext` is 
explicitly shared by concurrent list workers.
   
   `AuthorizationRequestContext`'s class javadoc states that entry 
authorization and list filtering of one request may share an instance and that 
list workers receive it explicitly - which is why its maps are 
`ConcurrentHashMap`. Two workers can therefore both be in 
`evaluateWithLoadedRolePolicies`: worker A reads generation 100 at line 1748, 
worker B reads 105, B finishes and writes 105, then A writes 100 and the 
recorded generation moves backwards.
   
   Nothing unsafe follows - a lower generation is the conservative direction - 
but it costs an extra detect/reload round on later checks, and those rounds 
count against `MAX_ROLE_POLICY_RELOADS`, so in the worst case a rollback 
contributes to a spurious hard deny. A max-based update 
(`setRolePolicyGeneration` keeping the larger value, or an `AtomicLong` with 
`accumulateAndGet(g, Math::max)`) removes the rollback entirely.
   
   Verified by: read `AuthorizationRequestContext` lines 55-61 (shared-instance 
contract) and 92-105/248-288 (plain `volatile` setters), against the read/write 
pair at lines 1748 and 1771.



##########
server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizer.java:
##########
@@ -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 to the role 
cache size. Guarded by
+   * the write lock of {@link #rolePolicyLock}.
+   */
+  private long maxRoleClearGenerations;

Review Comment:
   [Nit] The javadoc's guard claim does not match how this field is written.
   
   The javadoc says "Guarded by the write lock of `rolePolicyLock`", but the 
only production write is `maxRoleClearGenerations = roleCacheSize;` at line 280 
in `initialize()`, with no lock held; the reads at line 1698 are the ones under 
the write lock. It is benign in practice because `initialize()` runs before the 
authorizer is used, but the comment points a future reader at a guarantee the 
code does not provide. Either say "set once in `initialize()`, read under the 
write lock", or make it `volatile` (the pruning test already mutates it by 
reflection, so `final` is not an option).
   
   Verified by: grepped every reference to `maxRoleClearGenerations` on this 
head - declaration at 189, write at 280, read at 1698, plus the reflective 
write in `testRoleClearGenerationsArePrunedWithoutMissingClears`.



##########
server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizer.java:
##########
@@ -1554,16 +1656,140 @@ private void clearRolePoliciesOnCacheRemoval(long 
roleId) {
             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 against role policies that are at least 
as complete as the
+   * ones the request loaded.
+   *
+   * <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 checks of the request stay valid because they ran against 
complete policies. This
+   * keeps each check's evaluation inside one read-locked section without 
holding the lock across
+   * metadata or DB lookups.
+   *
+   * @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) {
+    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, new ArrayList<>(requestContext.getBoundRoleIds()), 
requestContext);

Review Comment:
   [Nit] The defensive copy here is redundant.
   
   `setBoundRoleIds` already stores `Collections.unmodifiableList(new 
ArrayList<>(...))` (`AuthorizationRequestContext.java:264-269`), and 
`versionCheckAndLoadRoles` only reads its `roleIds` argument 
(`roleIds.stream().distinct()` at line 1430). Passing 
`requestContext.getBoundRoleIds()` directly drops an allocation that happens on 
every reload attempt of every check.
   
   Verified by: read `setBoundRoleIds`/`getBoundRoleIds` in 
`AuthorizationRequestContext.java` and every use of `roleIds` inside 
`versionCheckAndLoadRoles` (1429-1555).



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to