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 5b640f9504 [#13691] fix(authorization): report deny while a role's 
policies are incomplete (#13707)
5b640f9504 is described below

commit 5b640f9504b7299b75d5579698f313ed273e34f2
Author: dennismdejong <[email protected]>
AuthorDate: Fri Nov 6 11:51:39 2026 +0100

    [#13691] fix(authorization): report deny while a role's policies are 
incomplete (#13707)
    
    ### What changes were proposed in this pull request?
    
    `JcasbinAuthorizer` now tracks which roles currently hold incomplete
    policies in a new `incompleteRolePolicyIds` set. While a role is in that
    set, `hasDenyPolicy` reports a possible deny for any request that holds
    it — directly or via a group, including inactive roles, since DENY
    remains global — so list authorization falls back to per-object checks
    instead of the parent-scope short-circuit.
    
    The check runs inside the existing `evaluateWithLoadedRolePolicies`
    callback and inspects the request's held-role subjects
    (`roleSubjects(requestContext, false)`), so it follows the
    request-scoped role model introduced by #13747 rather than the shared
    JCasbin grouping rows.
    
    ### Why are the changes needed?
    
    `hasDenyPolicy` answers an existence question — "can a DENY hide one of
    the listed objects?" — by scanning the deny enforcer's rows for the
    request's roles. A role load that cannot resolve a securable object
    installs only the rows it could resolve, so the missing deny row makes
    the scan return `false`, and `MetadataAuthzHelper` may then take the
    parent-scope short-circuit.
    
    `partialRoleLoadBackoff` was the only available evidence of an
    incomplete load, and it cannot carry that answer: it is a retry throttle
    whose expiry is unrelated to the installed rows, and it is not even
    re-armed when the retry cannot read the role entity. The new marker is
    added and removed only together with the policy rows it describes, so
    the two lifetimes always coincide.
    
    Deliberately conservative: the marker is role-scoped, not
    privilege-scoped, so a role that stays unresolvable (e.g. it still
    references a dropped table) keeps the list short-circuit disabled for
    its holders.
    
    Fix: #13691
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes, but the guarantee is limited: a request holding a partially loaded
    role no longer takes the parent-scope list short-circuit and falls back
    to per-object authorization, which may be slower.
    
    This does **not** by itself make a not-yet-resolved DENY effective. The
    deny row is still absent from the enforcer, so an object that DENY is
    meant to hide can remain in the list until the role reloads completely
    and installs the row. Today the list result is the same as with the
    short-circuit, because the per-object path reads the same deny rows. The
    marker keeps `hasDenyPolicy` truthful, so a future fix for the gap
    (#14379) cannot be bypassed by the short-circuit.
    
    Note on the #13691 reproduction: since #13699, a transient normalization
    failure already installs a catalog-scoped deny guard that the existing
    scan sees, and an entity-store failure fails closed. The marker
    therefore only matters when a DENY object genuinely resolves to
    NotFound, for example after a concurrent rename (#14379).
    
    ### How was this patch tested?
    
    Added regression tests in `TestJcasbinAuthorizer`:
    
    - `testHasDenyPolicyReportsMissingDenyOfIncompleteRoleLoad`: initial
    partial load; asserts the deny row is genuinely absent from the deny
    enforcer; then expires the retry throttle and makes the retry
    unreadable, so nothing re-arms the throttle — the possible deny must
    still be reported.
    - `testHasDenyPolicyRecoversAfterCompleteRoleLoad`: recovery after a
    complete load (marker cleared, answer goes back to `false` for an
    ALLOW-only role), and a partial reload over a complete load re-arms the
    marker while the resolvable rows stay installed.
    - `testCompleteResultAfterPartialLoadClearsIncompleteMarker`:
    complete-after-partial replacement, plus explicit
    `handleRolePrivilegeChange` invalidation clearing the marker.
    -
    `testListStillIncludesObjectDeniedByIncompleteRoleUntilCompleteReload`:
    drives the real `MetadataAuthzHelper.filterByExpression`. Documents the
    actual list result — the catalog stays listed while the deny row is
    missing (even though the short-circuit is disabled) and is excluded only
    after a complete reload installs the row.
    - `testPartialResultCannotOverwriteCompletedConcurrentLoad` (extended):
    a late partial result must not mark a role that a concurrent complete
    load already finished.
    
    `./gradlew :server-common:test --tests
    "org.apache.gravitino.server.authorization.jcasbin.*" -PskipITs` passes
    (181 tests, 0 failures), and `:server-common:spotlessCheck` passes. The
    branch is rebased onto current `main`.
    
    ---------
    
    Co-authored-by: yuqi <[email protected]>
---
 .../authorization/jcasbin/JcasbinAuthorizer.java   |  68 +++-
 .../jcasbin/TestJcasbinAuthorizer.java             | 364 +++++++++++++++++++++
 2 files changed, 429 insertions(+), 3 deletions(-)

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 695b61c299..0b8e8eae34 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
@@ -120,6 +120,13 @@ import org.slf4j.LoggerFactory;
  * 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}.
+ *
+ * <p>A role whose securable objects could not all be resolved is recorded in 
{@link
+ * #incompleteRolePolicyIds} for as long as its partial rows are installed. 
{@link #hasDenyPolicy}
+ * then reports a possible deny for any request holding that role: the deny 
enforcer's rows are
+ * missing the unresolved objects, so they cannot prove that no DENY hides one 
of the listed
+ * objects. This only disables the list short-circuit; per-object evaluation 
is unchanged, so an
+ * unresolvable DENY still cannot hide an object it never resolved to.
  */
 public class JcasbinAuthorizer implements GravitinoAuthorizer {
 
@@ -243,6 +250,25 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
    */
   private GravitinoCache<Long, Boolean> partialRoleLoadBackoff;
 
+  /**
+   * Role ids (the same string form used as the {@code sub} field of a policy 
row) whose currently
+   * installed policies are known to be incomplete, i.e. at least one 
securable object of the role
+   * could not be resolved to a metadata id during its last load.
+   *
+   * <p>An entry means the role's rows in {@link #denyEnforcer} cannot prove 
the absence of a DENY:
+   * the missing object may have carried one. {@link #hasDenyPolicy} therefore 
reports a possible
+   * deny for any request holding such a role, disabling the list 
short-circuit. It does not make
+   * the missing DENY itself effective; per-object evaluation is unchanged.
+   *
+   * <p>Unlike {@link #partialRoleLoadBackoff} — a retry throttle that expires 
on its own — an entry
+   * is added and removed only together with the policy set it describes: 
added by {@link
+   * #replaceRolePolicies} when a partial result is applied, and removed when 
a complete result
+   * replaces it or when {@link #clearRolePoliciesWithoutLock} drops the 
role's rows. Guarded by
+   * {@link #rolePolicyLock}; the lifetimes of the marker and of the partial 
policies therefore
+   * always coincide.
+   */
+  private final Set<String> incompleteRolePolicyIds = new HashSet<>();
+
   // ---- Eventual consistency caches (poller-driven) ----
 
   /** Path-based metadata object key -> entity id. Evicted by entity change 
poller. */
@@ -488,6 +514,29 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
             metalake,
             requestContext,
             () -> {
+              // The request holds these roles (direct + group-inherited); 
inactive roles are
+              // included because a DENY remains in effect regardless of 
active-role narrowing.
+              Set<String> heldRoleIds = roleSubjects(requestContext, false);
+
+              // A role whose securable objects could not all be resolved 
installs only the rows it
+              // could resolve, so its deny rows may be missing. This method 
answers an existence
+              // question — "can a DENY on these privileges hide one of the 
listed objects?" — and
+              // missing rows can never prove the absence of a DENY, so a held 
role with incomplete
+              // policies must be reported as possibly holding one. The caller 
then falls back to
+              // per-object checks instead of the parent-scope short-circuit. 
Note that this only
+              // disables that short-circuit: per-object evaluation is 
unchanged, so an unresolvable
+              // DENY still cannot hide an object it never resolved to.
+              for (String roleId : heldRoleIds) {
+                if (incompleteRolePolicyIds.contains(roleId)) {
+                  LOG.debug(
+                      "Reporting a possible deny for user {} because role {} 
holds incomplete"
+                          + " policies",
+                      userId,
+                      roleId);
+                  return true;
+                }
+              }
+
               // 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
@@ -496,7 +545,7 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
               // 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 : roleSubjects(requestContext, false)) {
+              for (String roleId : heldRoleIds) {
                 // 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
@@ -1557,8 +1606,16 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
       if (resolved.isComplete()) {
         loadedRoles.put(roleId, dbUpdatedAt);
         partialRoleLoadBackoff.invalidate(roleId);
+        // The installed rows are now the role's complete policy set, so the 
deny enforcer proves
+        // the absence of a DENY on these privileges again. Defensive: a 
previous partial load
+        // leaves no loadedRoles marker, so clearRolePoliciesWithoutLock above 
has normally removed
+        // the entry already.
+        incompleteRolePolicyIds.remove(String.valueOf(roleId));
       } else {
         partialRoleLoadBackoff.put(roleId, Boolean.TRUE);
+        // The rows applied above omit every securable object that did not 
resolve; remember that
+        // this role may be hiding a DENY so hasDenyPolicy cannot clear the 
list short-circuit.
+        incompleteRolePolicyIds.add(String.valueOf(roleId));
       }
       return true;
     } finally {
@@ -1628,6 +1685,10 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
     String roleIdStr = String.valueOf(roleId);
     boolean allowRemoved = allowEnforcer.removeFilteredPolicy(0, roleIdStr);
     boolean denyRemoved = denyEnforcer.removeFilteredPolicy(0, roleIdStr);
+    // The marker describes these rows, so it must go with them. Leaving it 
behind would keep the
+    // list short-circuit disabled for every request holding this role even 
though nothing is
+    // missing.
+    incompleteRolePolicyIds.remove(roleIdStr);
     return allowRemoved || denyRemoved;
   }
 
@@ -1676,8 +1737,9 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
    *
    * <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.
+   * Partial role-policy resolution keeps its existing per-object behavior; 
the deny-existence scan
+   * only learns that such a role may hold a missing DENY through {@link 
#incompleteRolePolicyIds}
+   * (see {@link #hasDenyPolicy}).
    *
    * @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
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 380b557f3d..949dc53900 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
@@ -1210,6 +1210,362 @@ public class TestJcasbinAuthorizer {
     assertFalse(
         getAllowEnforcer(jcasbinAuthorizer).getFilteredPolicy(0, 
roleIdStr).isEmpty(),
         "the completed load's policies must survive a late partial result");
+    assertFalse(
+        getIncompleteRolePolicyIds(jcasbinAuthorizer).contains(roleIdStr),
+        "a late partial result must not mark a completely loaded role as 
incomplete");
+  }
+
+  @Test
+  public void testCompleteResultAfterPartialLoadClearsIncompleteMarker() 
throws Exception {
+    // Mirror of testPartialResultCannotOverwriteCompletedConcurrentLoad with 
the opposite ordering:
+    // the partial result wins first, then a complete result for the same 
version replaces it.
+    String roleIdStr = String.valueOf(ALLOW_ROLE_ID);
+    String[] policyRow =
+        new String[] {
+          roleIdStr,
+          MetadataObject.Type.CATALOG.name(),
+          String.valueOf(CATALOG_ID),
+          USE_CATALOG.name(),
+          AuthConstants.ALLOW
+        };
+    ResolvedRolePolicies complete =
+        new ResolvedRolePolicies(
+            ImmutableList.<String[]>of(policyRow),
+            Collections.emptyList(),
+            Collections.emptyList());
+    ResolvedRolePolicies partial =
+        new ResolvedRolePolicies(
+            Collections.emptyList(),
+            Collections.emptyList(),
+            ImmutableList.of("CATALOG:testCatalog"));
+    long roleVersion = 43L;
+
+    assertTrue(invokeReplaceRolePolicies(jcasbinAuthorizer, ALLOW_ROLE_ID, 
roleVersion, partial));
+    assertTrue(
+        getIncompleteRolePolicyIds(jcasbinAuthorizer).contains(roleIdStr),
+        "a partial load must mark the role's policies as incomplete");
+    assertFalse(
+        
getLoadedRolesCache(jcasbinAuthorizer).getIfPresent(ALLOW_ROLE_ID).isPresent(),
+        "a partial load must stay out of loadedRoles");
+
+    // The complete result replaces the partial rows, so nothing can be 
missing any longer.
+    assertTrue(invokeReplaceRolePolicies(jcasbinAuthorizer, ALLOW_ROLE_ID, 
roleVersion, complete));
+    assertFalse(
+        getIncompleteRolePolicyIds(jcasbinAuthorizer).contains(roleIdStr),
+        "a complete load must clear the incomplete marker");
+    assertFalse(
+        getAllowEnforcer(jcasbinAuthorizer).getFilteredPolicy(0, 
roleIdStr).isEmpty(),
+        "the complete load's policies must be installed");
+
+    // An explicit invalidation drops the rows, so the marker must not outlive 
them. Re-arm the
+    // marker first with a newer partial load that still installs a row; 
otherwise the assertion
+    // below would pass even if clearing the rows left the marker behind.
+    ResolvedRolePolicies partialWithRow =
+        new ResolvedRolePolicies(
+            ImmutableList.<String[]>of(policyRow),
+            Collections.emptyList(),
+            ImmutableList.of("CATALOG:testCatalog"));
+    assertTrue(
+        invokeReplaceRolePolicies(
+            jcasbinAuthorizer, ALLOW_ROLE_ID, roleVersion + 1, 
partialWithRow));
+    assertTrue(
+        getIncompleteRolePolicyIds(jcasbinAuthorizer).contains(roleIdStr),
+        "a newer partial load must re-arm the incomplete marker");
+
+    jcasbinAuthorizer.handleRolePrivilegeChange(ALLOW_ROLE_ID);
+    assertTrue(
+        getAllowEnforcer(jcasbinAuthorizer).getFilteredPolicy(0, 
roleIdStr).isEmpty(),
+        "an explicit invalidation must drop the partial rows");
+    assertFalse(
+        getIncompleteRolePolicyIds(jcasbinAuthorizer).contains(roleIdStr),
+        "clearing a role's policies must also clear its incomplete marker");
+  }
+
+  @Test
+  public void testHasDenyPolicyReportsMissingDenyOfIncompleteRoleLoad() throws 
Exception {
+    Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
+
+    // The user holds a role that DENIES USE_CATALOG on a parent scope, but 
the role's securable
+    // object cannot be resolved, so no deny row is installed. hasDenyPolicy 
answers an existence
+    // question ("can a DENY hide one of the listed objects?"), and a row that 
was never installed
+    // cannot prove there is none. Reporting "no deny" would let list 
authorization take the
+    // parent-scope short-circuit; reporting one forces the per-object 
evaluation, which is the
+    // strongest guarantee this fix provides (it does not make the missing 
DENY effective; see
+    // testListStillIncludesObjectDeniedByIncompleteRoleUntilCompleteReload).
+    mockRoleInStore(DENY_ROLE_ID, "denyRole", 
ImmutableList.of(getDenySecurableObject()));
+    when(roleMetaMapper.listRolesByUserId(eq(USER_ID)))
+        .thenReturn(ImmutableList.of(buildRolePO(DENY_ROLE_ID, "denyRole")));
+    when(userMetaMapper.getUserUpdatedAt(eq(METALAKE), eq(USERNAME)))
+        .thenReturn(new UserUpdatedAt(USER_ID, nextUserVersion()));
+
+    metadataIdConverterMockedStatic
+        .when(() -> MetadataIdConverter.getID(any(), eq(METALAKE)))
+        .thenReturn(Optional.empty());
+    try {
+      assertTrue(
+          jcasbinAuthorizer.hasDenyPolicy(
+              currentPrincipal,
+              METALAKE,
+              ImmutableSet.of(USE_CATALOG),
+              new AuthorizationRequestContext()),
+          "an incomplete role load must be reported as possibly holding a 
DENY");
+      assertTrue(
+          getDenyEnforcer(jcasbinAuthorizer)
+              .getFilteredNamedPolicy("p", 0, String.valueOf(DENY_ROLE_ID))
+              .isEmpty(),
+          "the deny row must really be absent, so the answer cannot come from 
the policy scan");
+    } finally {
+      metadataIdConverterMockedStatic
+          .when(() -> MetadataIdConverter.getID(any(), eq(METALAKE)))
+          .thenReturn(Optional.of(CATALOG_ID));
+    }
+
+    // The retry throttle is a TTL cache and lapses independently of the 
partial policies. In
+    // production it can expire after loadRolePrivilege and before the 
evaluation, while the partial
+    // rows are still installed. The null entity below is only a test stand-in 
for that window: it
+    // keeps the retry from reloading the role or re-arming the throttle. (A 
role that really cannot
+    // be read throws instead, is marked unreadable, and hasDenyPolicy fails 
closed.) The marker,
+    // not the throttle, must carry the answer.
+    getPartialRoleLoadBackoffCache(jcasbinAuthorizer).invalidate(DENY_ROLE_ID);
+    when(entityStore.get(
+            eq(NameIdentifierUtil.ofRole(METALAKE, "denyRole")),
+            eq(Entity.EntityType.ROLE),
+            eq(RoleEntity.class)))
+        .thenReturn(null);
+
+    assertTrue(
+        jcasbinAuthorizer.hasDenyPolicy(
+            currentPrincipal,
+            METALAKE,
+            ImmutableSet.of(USE_CATALOG),
+            new AuthorizationRequestContext()),
+        "the deny must still be reported after the retry throttle expired");
+    assertFalse(
+        
getPartialRoleLoadBackoffCache(jcasbinAuthorizer).getIfPresent(DENY_ROLE_ID).isPresent(),
+        "the retry must not have re-armed the throttle, proving the marker 
carries the answer");
+    assertTrue(
+        
getIncompleteRolePolicyIds(jcasbinAuthorizer).contains(String.valueOf(DENY_ROLE_ID)),
+        "the incomplete marker must survive the retry throttle");
+  }
+
+  @Test
+  public void testHasDenyPolicyRecoversAfterCompleteRoleLoad() throws 
Exception {
+    Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
+
+    // An ALLOW-only role so that the deny enforcer never holds a row: every 
hasDenyPolicy answer
+    // below is driven solely by policy completeness, which makes the recovery 
observable.
+    RoleEntity allowRole =
+        mockRoleInStore(ALLOW_ROLE_ID, "allowRole", 
ImmutableList.of(getAllowSecurableObject()));
+    mockDirectUserRoles(allowRole);
+
+    try {
+      metadataIdConverterMockedStatic
+          .when(() -> MetadataIdConverter.getID(any(), eq(METALAKE)))
+          .thenReturn(Optional.empty());
+      assertTrue(
+          jcasbinAuthorizer.hasDenyPolicy(
+              currentPrincipal,
+              METALAKE,
+              ImmutableSet.of(USE_CATALOG),
+              new AuthorizationRequestContext()),
+          "no policy could be installed, so the DENY enforcer cannot be 
trusted yet");
+      assertTrue(
+          
getIncompleteRolePolicyIds(jcasbinAuthorizer).contains(String.valueOf(ALLOW_ROLE_ID)));
+
+      // 1. Recovery: once every object resolves the role loads completely, 
the marker goes away and
+      //    the deny enforcer is authoritative again.
+      metadataIdConverterMockedStatic
+          .when(() -> MetadataIdConverter.getID(any(), eq(METALAKE)))
+          .thenReturn(Optional.of(CATALOG_ID));
+      
getPartialRoleLoadBackoffCache(jcasbinAuthorizer).invalidate(ALLOW_ROLE_ID);
+      assertFalse(
+          jcasbinAuthorizer.hasDenyPolicy(
+              currentPrincipal,
+              METALAKE,
+              ImmutableSet.of(USE_CATALOG),
+              new AuthorizationRequestContext()),
+          "a completely loaded ALLOW-only role holds no deny");
+      assertFalse(
+          
getIncompleteRolePolicyIds(jcasbinAuthorizer).contains(String.valueOf(ALLOW_ROLE_ID)));
+      
assertTrue(getLoadedRolesCache(jcasbinAuthorizer).getIfPresent(ALLOW_ROLE_ID).isPresent());
+
+      // 2. Partial reloads: a newer version of an already complete role that 
fails to resolve one
+      //    securable object must re-arm the marker.
+      RoleEntity partiallyResolvable =
+          getRoleEntity(
+              ALLOW_ROLE_ID,
+              "allowRole",
+              ImmutableList.of(
+                  getAllowSecurableObject(),
+                  buildSecurableObject(
+                      ALLOW_ROLE_ID,
+                      MetadataObject.Type.CATALOG,
+                      "unresolvableCatalog",
+                      USE_CATALOG,
+                      "ALLOW")));
+      when(entityStore.get(
+              eq(NameIdentifierUtil.ofRole(METALAKE, "allowRole")),
+              eq(Entity.EntityType.ROLE),
+              eq(RoleEntity.class)))
+          .thenReturn(partiallyResolvable);
+      mockedRoleVersions.put(
+          ALLOW_ROLE_ID, new RoleUpdatedAt(ALLOW_ROLE_ID, "allowRole", 
nextRoleVersion()));
+      metadataIdConverterMockedStatic
+          .when(() -> MetadataIdConverter.getID(any(), eq(METALAKE)))
+          .thenAnswer(
+              invocation -> {
+                MetadataObject object = invocation.getArgument(0);
+                return object.fullName().contains("unresolvableCatalog")
+                    ? Optional.empty()
+                    : Optional.of(CATALOG_ID);
+              });
+      
getPartialRoleLoadBackoffCache(jcasbinAuthorizer).invalidate(ALLOW_ROLE_ID);
+
+      assertTrue(
+          jcasbinAuthorizer.hasDenyPolicy(
+              currentPrincipal,
+              METALAKE,
+              ImmutableSet.of(USE_CATALOG),
+              new AuthorizationRequestContext()),
+          "a partial reload must re-arm the marker even though the previous 
load was complete");
+      assertTrue(
+          
getIncompleteRolePolicyIds(jcasbinAuthorizer).contains(String.valueOf(ALLOW_ROLE_ID)));
+      assertFalse(
+          getAllowEnforcer(jcasbinAuthorizer)
+              .getFilteredPolicy(0, String.valueOf(ALLOW_ROLE_ID))
+              .isEmpty(),
+          "the resolvable securable object must still install its row");
+    } finally {
+      metadataIdConverterMockedStatic
+          .when(() -> MetadataIdConverter.getID(any(), eq(METALAKE)))
+          .thenReturn(Optional.of(CATALOG_ID));
+    }
+  }
+
+  /**
+   * Documents the limit of the incomplete-role marker through the real list 
filter: disabling the
+   * parent-scope short-circuit does not make an unresolved DENY hide its 
object. Per-object
+   * evaluation is unchanged, so the object stays listed until the role 
reloads completely and
+   * installs the deny row. The remaining authorization gap is tracked 
separately.
+   */
+  @Test
+  public void 
testListStillIncludesObjectDeniedByIncompleteRoleUntilCompleteReload()
+      throws Exception {
+    Field executorField = 
MetadataAuthzHelper.class.getDeclaredField("executor");
+    executorField.setAccessible(true);
+    Object previousExecutor = executorField.get(null);
+    executorField.set(null, (Executor) Runnable::run);
+    Config previousConfig = gravitinoEnv.config();
+    Config config = mock(Config.class);
+    when(config.get(eq(Configs.ENABLE_AUTHORIZATION))).thenReturn(true);
+    when(gravitinoEnv.config()).thenReturn(config);
+    try {
+      // A metalake-level ALLOW USE_CATALOG satisfies the catalog-list 
parent-scope path, and a
+      // catalog-level DENY USE_CATALOG on the same catalog must invalidate it.
+      RoleEntity allowRole =
+          mockRoleInStore(
+              ALLOW_ROLE_ID,
+              "listAllowRole",
+              ImmutableList.of(
+                  buildSecurableObject(
+                      ALLOW_ROLE_ID,
+                      MetadataObject.Type.METALAKE,
+                      METALAKE,
+                      USE_CATALOG,
+                      "ALLOW")));
+      RoleEntity denyRole =
+          mockRoleInStore(
+              DENY_ROLE_ID,
+              "listDenyRole",
+              ImmutableList.of(
+                  buildSecurableObject(
+                      DENY_ROLE_ID,
+                      MetadataObject.Type.CATALOG,
+                      "testCatalog",
+                      USE_CATALOG,
+                      "DENY")));
+      mockDirectUserRoles(allowRole, denyRole);
+
+      // The DENY's catalog cannot be resolved while the role loads, so only 
the ALLOW role
+      // installs rows and the deny enforcer has no row for this catalog.
+      metadataIdConverterMockedStatic
+          .when(() -> MetadataIdConverter.getID(any(), eq(METALAKE)))
+          .thenAnswer(
+              invocation -> {
+                MetadataObject object = invocation.getArgument(0);
+                return object.type() == MetadataObject.Type.CATALOG
+                        && "testCatalog".equals(object.fullName())
+                    ? Optional.empty()
+                    : Optional.of(CATALOG_ID);
+              });
+
+      AuthorizationRequestContext context = new AuthorizationRequestContext();
+      assertTrue(
+          jcasbinAuthorizer.hasDenyPolicy(
+              PrincipalUtils.getCurrentPrincipal(),
+              METALAKE,
+              ImmutableSet.of(USE_CATALOG),
+              context),
+          "the incomplete role must be reported as possibly holding a DENY");
+      assertTrue(
+          getDenyEnforcer(jcasbinAuthorizer)
+              .getFilteredNamedPolicy("p", 0, String.valueOf(DENY_ROLE_ID))
+              .isEmpty(),
+          "the deny row must really be absent");
+
+      // The per-object path can resolve the catalog now, but the deny row is 
still missing, so the
+      // listed catalog is still returned. This is the documented limit of the 
fix.
+      metadataIdConverterMockedStatic
+          .when(() -> MetadataIdConverter.getID(any(), eq(METALAKE)))
+          .thenReturn(Optional.of(CATALOG_ID));
+
+      GravitinoAuthorizerProvider provider = 
mock(GravitinoAuthorizerProvider.class);
+      when(provider.getGravitinoAuthorizer()).thenReturn(jcasbinAuthorizer);
+      NameIdentifier deniedCatalog = NameIdentifier.of(METALAKE, 
"testCatalog");
+      try (MockedStatic<GravitinoAuthorizerProvider> providerMock =
+              mockStatic(GravitinoAuthorizerProvider.class);
+          AuthorizationRequestScope scope = AuthorizationRequestScope.open()) {
+        
providerMock.when(GravitinoAuthorizerProvider::getInstance).thenReturn(provider);
+        scope.bind(METALAKE, context);
+        NameIdentifier[] filtered =
+            MetadataAuthzHelper.filterByExpression(
+                METALAKE,
+                
AuthorizationExpressionConstants.LOAD_CATALOG_AUTHORIZATION_EXPRESSION,
+                Entity.EntityType.CATALOG,
+                new NameIdentifier[] {deniedCatalog});
+        assertEquals(
+            1,
+            filtered.length,
+            "the unresolved DENY row cannot hide the catalog: per-object 
evaluation still allows it");
+      }
+
+      // A complete reload of the same DENY installs the row, and the 
per-object path now excludes
+      // the catalog.
+      
getPartialRoleLoadBackoffCache(jcasbinAuthorizer).invalidate(DENY_ROLE_ID);
+      AuthorizationRequestContext reloadedContext = new 
AuthorizationRequestContext();
+      try (MockedStatic<GravitinoAuthorizerProvider> providerMock =
+              mockStatic(GravitinoAuthorizerProvider.class);
+          AuthorizationRequestScope scope = AuthorizationRequestScope.open()) {
+        
providerMock.when(GravitinoAuthorizerProvider::getInstance).thenReturn(provider);
+        scope.bind(METALAKE, reloadedContext);
+        NameIdentifier[] filtered =
+            MetadataAuthzHelper.filterByExpression(
+                METALAKE,
+                
AuthorizationExpressionConstants.LOAD_CATALOG_AUTHORIZATION_EXPRESSION,
+                Entity.EntityType.CATALOG,
+                new NameIdentifier[] {deniedCatalog});
+        assertEquals(0, filtered.length, "a completely loaded DENY must hide 
the catalog again");
+      }
+      assertTrue(
+          getIncompleteRolePolicyIds(jcasbinAuthorizer).isEmpty(),
+          "the complete reload must clear the incomplete marker");
+    } finally {
+      executorField.set(null, previousExecutor);
+      when(gravitinoEnv.config()).thenReturn(previousConfig);
+      metadataIdConverterMockedStatic
+          .when(() -> MetadataIdConverter.getID(any(), eq(METALAKE)))
+          .thenReturn(Optional.of(CATALOG_ID));
+    }
   }
 
   @Test
@@ -4463,6 +4819,14 @@ public class TestJcasbinAuthorizer {
     return (GravitinoCache<Long, Boolean>) field.get(authorizer);
   }
 
+  @SuppressWarnings("unchecked")
+  private static Set<String> getIncompleteRolePolicyIds(JcasbinAuthorizer 
authorizer)
+      throws Exception {
+    Field field = 
JcasbinAuthorizer.class.getDeclaredField("incompleteRolePolicyIds");
+    field.setAccessible(true);
+    return (Set<String>) field.get(authorizer);
+  }
+
   @SuppressWarnings("unchecked")
   private static GravitinoCache<Long, Optional<OwnerInfo>> getOwnerRelCache(
       JcasbinAuthorizer authorizer) throws Exception {

Reply via email to