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


##########
server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizationLookups.java:
##########
@@ -131,11 +97,85 @@ public Optional<OwnerInfo> resolveOwnerId(
         id -> ownerRelCache.get(id, k -> loadOwner(k, metadataType)));
   }
 
+  // Preserve normalization failures as distinct from missing objects for 
DENY-policy consumers.
+  MetadataIdResolution resolveMetadataIdResult(
+      MetadataObject metadataObject, String metalake, 
AuthorizationRequestContext requestContext) {
+    MetadataObject cacheObject;
+    try {
+      // Use the same capability rules as ID resolution so hooks and peer 
change-log replay
+      // evict every equivalent spelling from both cache tiers.
+      cacheObject =
+          requestContext.computeNormalizedMetadataObjectIfAbsent(
+              JcasbinAuthorizationCacheKeys.metadataIdCacheKey(metalake, 
metadataObject),
+              ignored -> 
MetadataIdConverter.normalizeMetadataObject(metadataObject, metalake));
+    } catch (NotFoundException e) {
+      return new MetadataIdResolution(Optional.empty(), false);
+    } catch (RuntimeException e) {
+      // Never fall back to the raw key: it can retain an ID after 
canonical-name invalidation.
+      // Catch only normalization failures; entity-store and cache-loader 
failures still propagate.
+      // Failures are not memoized, so a transient one can succeed on retry in 
the same request.
+      // The cost: while a catalog cannot initialize, every object of that 
catalog looked up in a
+      // request reloads it (connector initialization included) and logs this 
warning again. A
+      // per-request, per-catalog failure memo or a once-per-request warning 
would bound that.
+      LOG.warn(
+          "Cannot normalize metadata object {}:{} in metalake {}; 
authorization lookup is unresolved",
+          metadataObject.type(),
+          metadataObject.fullName(),
+          metalake,
+          e);
+      return new MetadataIdResolution(Optional.empty(), true);

Review Comment:
   [Important] A failed capability lookup is memoized nowhere, so one broken 
catalog now costs many connector-initialization attempts per request instead of 
one.
   
   Two layers both decline to remember the failure. 
`computeNormalizedMetadataObjectIfAbsent` uses `computeIfAbsent` 
(`AuthorizationRequestContext.java:225-230`), which records no mapping when the 
loader throws, and `CatalogManager.loadCatalogAndWrapInternal` goes through 
`catalogCache.get(ident, this::loadCatalogInternal)` 
(`CatalogManager.java:1642-1654`), which Caffeine does not populate when its 
loader throws. So every lookup that reaches here re-runs `createCatalogWrapper` 
and the connector's `initialize()`.
   
   Count for one request on such a catalog under `ANY(DENY_SELECT_TABLE, 
METALAKE, CATALOG, SCHEMA, TABLE)` 
(`AuthorizationExpressionConverter.java:256`): `SCHEMA` and `TABLE` are both 
capability-scoped and the allow and deny authorizers each resolve both, so 4 
attempts for the checked object alone; plus one per distinct securable object 
of that catalog while loading each role (`JcasbinAuthorizer.java:1873`); plus a 
repeat of all of it on each `evaluateWithLoadedRolePolicies` reload, up to 
`MAX_ROLE_POLICY_RELOADS` = 3. On `origin/main` the first failure aborted the 
request after exactly one attempt. If `initialize()` blocks on a connect 
timeout (Hive metastore client, JDBC pool), the request holds its thread for a 
multiple of that timeout and logs this WARN with a stack trace each time; 
enough concurrent requests and the amplification reaches the request thread 
pool, working against this change's "other catalogs stay usable" goal.
   
   The comment here already names the fix. A per-request set of `(metalake, 
catalog)` pairs whose capability resolution failed, consulted before 
`normalizeMetadataObject` and returning the same unresolved result, bounds this 
to one attempt and one WARN per catalog per request. It weakens nothing: the 
outcome for every object of that catalog in that request is already identical, 
and a per-request memo still lets the next request observe recovery.
   
   Verified by: reading `computeNormalizedMetadataObjectIfAbsent`, 
`normalizeMetadataObject` into `CapabilityHelpers.getCapability` (`:55-64`) 
into 
`CatalogManager.doWithCatalog`/`loadCatalogAndWrapInternal`/`loadCatalogInternal`,
 both `resolveMetadataIdResult` call sites (`JcasbinAuthorizer.java:973`, 
`:1873`), and the DENY lists in `AuthorizationExpressionConverter.java:241-374` 
in this checkout.



##########
server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizer.java:
##########
@@ -1861,37 +1869,91 @@ private ResolvedRolePolicies resolveRolePolicies(
     List<String> unresolvedObjects = new ArrayList<>();
 
     for (SecurableObject securableObject : securableObjects) {
-      Optional<Long> metadataId =
-          lookups.resolveMetadataId(securableObject, metalake, requestContext);
+      JcasbinAuthorizationLookups.MetadataIdResolution resolution =
+          lookups.resolveMetadataIdResult(securableObject, metalake, 
requestContext);
+      Optional<Long> metadataId = resolution.metadataId();
       if (!metadataId.isPresent()) {
         unresolvedObjects.add(securableObject.type().name() + ":" + 
securableObject.fullName());
+        if (resolution.normalizationFailed()) {
+          addUnresolvedDenyPolicies(
+              securableObject, metalake, roleIdStr, requestContext, allowRows, 
denyRows);
+        }
         continue;
       }
-      String metadataIdStr = String.valueOf(metadataId.get());
-      for (Privilege privilege : securableObject.privileges()) {
-        Privilege.Condition condition = privilege.condition();
-        String action =
-            AuthorizationUtils.replaceLegacyPrivilegeName(privilege.name())
-                .name()
-                .toUpperCase(Locale.ROOT);
-        if (AuthConstants.DENY.equalsIgnoreCase(condition.name())) {
-          denyRows.add(
-              new String[] {
-                roleIdStr, securableObject.type().name(), metadataIdStr, 
action, AuthConstants.ALLOW
-              });
-        }
+      addPolicyRows(
+          roleIdStr,
+          securableObject.type(),
+          metadataId.get(),
+          securableObject.privileges(),
+          allowRows,
+          denyRows);
+    }
+    return new ResolvedRolePolicies(allowRows, denyRows, unresolvedObjects);
+  }
 
-        allowRows.add(
+  private void addUnresolvedDenyPolicies(
+      SecurableObject object,
+      String metalake,
+      String roleId,
+      AuthorizationRequestContext requestContext,
+      List<String[]> allowRows,
+      List<String[]> denyRows) {
+    List<Privilege> denies =
+        object.privileges().stream()
+            .filter(privilege -> privilege.condition() == 
Privilege.Condition.DENY)
+            .collect(Collectors.toList());
+    if (denies.isEmpty()) {
+      return;
+    }
+    // An unresolved child DENY must not disappear while an ancestor or 
another role grants
+    // ALLOW. Conservatively deny the same privileges throughout its catalog 
until the existing
+    // partial-role retry resolves the precise object again. Catalog IDs need 
no capability lookup,
+    // and other catalogs remain usable. A missing entity alone does not 
broaden existing policies.
+    MetadataObject catalog =
+        NameIdentifierUtil.toMetadataObject(
+            NameIdentifierUtil.getCatalogIdentifier(
+                MetadataObjectUtil.toEntityIdent(metalake, object)),
+            Entity.EntityType.CATALOG);
+    Optional<Long> catalogId = lookups.resolveMetadataId(catalog, metalake, 
requestContext);
+    if (!catalogId.isPresent()) {
+      // Without a catalog ID even a catalog-scoped guard cannot be installed 
safely. A missing
+      // catalog makes normalization report a missing object instead, so this 
is only reachable
+      // when the catalog is dropped between the two lookups. Skipping the 
DENY could drop it for
+      // a catalog recreated under the same name, so fail this load; the next 
request retries.
+      throw new IllegalStateException(
+          "Cannot resolve catalog for unresolved deny policy on " + 
object.fullName());

Review Comment:
   [Question] On the initial role load this `IllegalStateException` still 
escapes as a 500, while the identical failure one call site later fails closed. 
Is the asymmetry deliberate?
   
   `resolveRolePolicies` runs inside `loadStaleRolePolicies`, which 
`loadRolePrivilege` calls with no guard (`JcasbinAuthorizer.java:1325-1327`), 
so this throw propagates out through `computeIfAbsent` in 
`AuthorizationRequestContext` and surfaces as a server error. The other caller 
wraps the same method in `catch (RuntimeException)` and returns 
`Optional.empty()`, which the authorizers turn into `unstablePolicyResult`, 
i.e. a 403 (`JcasbinAuthorizer.java:1765-1779`).
   
   I agree the window is narrow, and the comment's reasoning holds up: 
`loadCatalogInternal` converts a missing catalog entity into 
`NoSuchCatalogException` (`CatalogManager.java:1746-1757`), which reaches 
`resolveMetadataIdResult` as a `NotFoundException` and is reported as a missing 
object, not a normalization failure - so this needs the catalog to disappear 
between the capability call and the catalog-ID lookup in the same request. 
`testUnresolvedDenyWithoutCatalogIdFailsBeforePoliciesAreApplied` pins the 
behaviour and shows no partial policies are published, which is the important 
half.
   
   Still, it is the one remaining path where an unresolvable catalog turns an 
authorization request into a 500 - the failure mode the rest of this commit 
removes. Adding the role to the `unreadableRoleIds` set that 
`loadStaleRolePolicies` already builds and returns 
(`JcasbinAuthorizer.java:1492`, `:1523`, `:1564`) would fail closed through the 
existing channel, publish nothing, and still retry on the next request, without 
the DENY-dropping risk the comment rightly rules out. If you prefer to keep the 
throw, a line in the PR description naming this residual 500 would settle it.
   
   Verified by: reading both `versionCheckAndLoadRoles` call sites and 
`evaluateWithLoadedRolePolicies` in this checkout, `loadCatalogInternal`'s 
exception mapping, and the `NotFoundException` branch at 
`JcasbinAuthorizationLookups.java:111-112`.



-- 
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