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

jerryshao 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 0782dd9a0e [#13694] fix(authorization): Normalize metadata ID cache 
keys (#13699)
0782dd9a0e is described below

commit 0782dd9a0e93f2d1118876d64d5ab917cc18e4c5
Author: Qi Yu <[email protected]>
AuthorDate: Sat Oct 10 18:48:11 2026 +0800

    [#13694] fix(authorization): Normalize metadata ID cache keys (#13699)
    
    ### What changes were proposed in this pull request?
    
    Normalize names by catalog capability before constructing JCasbin's
    shared and per-request metadata ID cache keys, using the same
    normalization implementation as ID resolution, and load IDs from that
    canonical name.
    
    - Case-sensitive names, semantic model leaves and types without a
    catalog scope keep their spelling; column schema/table parents follow
    their own scopes.
    - VIEW and FUNCTION are now mapped to their capability scopes, matching
    the names their normalize dispatchers store. `EntityClassMapper`
    registers their entity classes.
    - Successful normalization is reused per raw name within a request.
    Capability failures are remembered per metalake/catalog for that
    request, including concurrent lookups, so connector initialization is
    attempted once after a failure and retried on the next request. Missing
    objects are still not cached.
    - A capability failure (for example, a catalog whose connector cannot
    initialize) no longer escapes as a server error. That object is
    unresolved and fails closed. A DENY on such an object installs a
    conservative catalog-scoped DENY until the existing partial-role retry
    resolves the precise object, so an ancestor or another role's ALLOW
    cannot bypass it. Other catalogs stay usable. If the catalog ID needed
    for the DENY guard disappears, the role is marked unreadable and the
    request is denied without publishing that role's policies; the next
    request retries. Entity-store failures still propagate.
    - A shared-cache miss loads the already-canonical object through
    `MetadataIdConverter.getIdForNormalizedObject`, without a second
    capability lookup.
    
    ### Why are the changes needed?
    
    For case-insensitive catalogs, a request such as `cat.DB.TBL` previously
    cached an ID under its raw spelling. Local hooks and peer change-log
    replay evicted only `cat.db.tbl`, leaving the alias to authorize against
    the old object's ID after name reuse. Views and functions keyed on the
    raw spelling, so a mixed-case request missed the stored entity.
    
    Fix: #13694
    
    ### Does this PR introduce _any_ user-facing change?
    
    Case-variant requests share one cache key, so normalized invalidation
    also removes their stale IDs. Mixed-case requests for views and
    functions in case-insensitive catalogs now resolve the stored object.
    While a catalog's capabilities cannot be resolved, requests on its
    objects are denied, and roles with a DENY in that catalog deny those
    privileges across the catalog until a subsequent request retries the
    partial role load after recovery, once the existing 10-second retry
    backoff permits it. No configuration or REST API changes.
    
    ### How was this patch tested?
    
    - Targeted tests pass: normalization suite (50), JCasbin authorizer
    (87), lookups (5), metadata ID converter (3), authorization request
    context (18), and all other `org.apache.gravitino.server.authorization`
    tests.
    - New cases cover capability failures on shared-cache hits and misses,
    bounded persistent failure and retry in the next request, entity-store
    failure propagation, healthy-catalog policy loading next to a failing
    one, catalog-scoped DENY guarding and its removal after retry, an
    already-loaded DENY that must not be bypassed, the canonical TABLE case
    in `TestJcasbinAuthorizationLookups`, and view/function normalization.
    - Additional mutations fail the new regressions: dropping per-catalog
    failure memory allows a second concurrent initialization, and dropping
    unreadable-role handling allows the ancestor grant despite the missing
    DENY guard.
    - Mutations checked to fail the suite: unresolved objects pass the deny
    check, the catalog-scoped DENY guard is dropped (including during the
    retry backoff after the catalog recovers), and the VIEW scope mapping is
    removed.
    - Core and server-common Spotless pass. Full non-Docker suites were run:
    server-common passed all 454 tests; core had one unrelated
    special-character job-path timeout under an invalid macOS locale (JVM
    US-ASCII), and that sole failing test passed when rerun with
    `LC_ALL=en_US.UTF-8`. Docker and integration suites were not run.
---
 .../authorization/AuthorizationRequestContext.java |  64 +++
 .../apache/gravitino/utils/EntityClassMapper.java  |   4 +
 .../TestAuthorizationRequestContext.java           | 140 +++++
 .../server/authorization/MetadataIdConverter.java  | 103 +++-
 .../jcasbin/JcasbinAuthorizationLookups.java       | 147 +++--
 .../authorization/jcasbin/JcasbinAuthorizer.java   | 134 +++--
 .../jcasbin/TestJcasbinAuthorizationLookups.java   |  63 ++-
 .../jcasbin/TestJcasbinAuthorizer.java             | 398 +++++++++++++-
 .../TestJcasbinMetadataIdCacheNormalization.java   | 611 +++++++++++++++++++++
 .../jcasbin/TestJcasbinModelCacheInvalidation.java |  10 +-
 10 files changed, 1571 insertions(+), 103 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 e504c359c4..8b453a1e1a 100644
--- 
a/core/src/main/java/org/apache/gravitino/authorization/AuthorizationRequestContext.java
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/AuthorizationRequestContext.java
@@ -32,12 +32,15 @@ import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.function.Function;
+import javax.annotation.Nullable;
 import lombok.AllArgsConstructor;
 import lombok.EqualsAndHashCode;
 import lombok.Getter;
 import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.UserPrincipal;
 import org.apache.gravitino.auth.ActiveRoles;
+import org.apache.gravitino.exceptions.NotFoundException;
 import org.apache.gravitino.storage.relational.po.auth.GroupUpdatedAt;
 import org.apache.gravitino.storage.relational.po.auth.OwnerInfo;
 import org.apache.gravitino.storage.relational.po.auth.RoleUpdatedAt;
@@ -80,6 +83,13 @@ public class AuthorizationRequestContext {
   /** Per-request group identity cache. Key: {@code metalake::groupName}. */
   private final Map<String, Optional<GroupUpdatedAt>> groupInfoCache = new 
ConcurrentHashMap<>();
 
+  /** Per-request raw-name → canonical metadata object cache. */
+  private final Map<String, MetadataObject> normalizedMetadataObjects = new 
ConcurrentHashMap<>();
+
+  /** Serializes normalization per catalog and retains capability failures for 
this request. */
+  private final Map<NameIdentifier, CatalogNormalizationState> 
catalogNormalizationStates =
+      new ConcurrentHashMap<>();
+
   /** Per-request name→id cache. Deduplicates resolveMetadataId within a 
single request. */
   private final Map<String, Long> metadataIdCache = new ConcurrentHashMap<>();
 
@@ -208,6 +218,56 @@ public class AuthorizationRequestContext {
         k -> Objects.requireNonNull(loader.apply(k), "Group info loader must 
not return null"));
   }
 
+  /**
+   * Normalizes each raw metadata name at most once per request.
+   *
+   * <p>The key must include the metalake, object type and raw name. 
Successful results are a
+   * request-local snapshot; failures are not cached, so a later lookup may 
retry. A fresh request
+   * must resolve current catalog rules again. This map must not be shared 
across mutations.
+   *
+   * @param key the raw metadata object's cache key
+   * @param loader the normalization function, which must return a non-null 
object or throw
+   * @return the canonical metadata object
+   */
+  public MetadataObject computeNormalizedMetadataObjectIfAbsent(
+      String key, Function<String, MetadataObject> loader) {
+    return normalizedMetadataObjects.computeIfAbsent(
+        key,
+        k -> Objects.requireNonNull(loader.apply(k), "Normalization loader 
must not return null"));
+  }
+
+  /**
+   * Normalizes a raw name while remembering capability failures for its 
catalog in this request.
+   *
+   * <p>Catalog lookups are serialized so concurrent objects cannot retry a 
failed initialization.
+   * Missing catalogs are not memoized; a fresh request can retry any 
capability failure.
+   *
+   * @param key the raw metadata object's cache key
+   * @param catalogIdentifier the containing catalog, including its metalake
+   * @param loader the normalization function, which must return a non-null 
object or throw
+   * @return the canonical metadata object
+   * @throws RuntimeException if normalization failed for this catalog
+   */
+  public MetadataObject computeNormalizedMetadataObjectIfAbsent(
+      String key, NameIdentifier catalogIdentifier, Function<String, 
MetadataObject> loader) {
+    CatalogNormalizationState state =
+        catalogNormalizationStates.computeIfAbsent(
+            catalogIdentifier, ignored -> new CatalogNormalizationState());
+    synchronized (state) {
+      if (state.failure != null) {
+        throw state.failure;
+      }
+      try {
+        return computeNormalizedMetadataObjectIfAbsent(key, loader);
+      } catch (NotFoundException e) {
+        throw e;
+      } catch (RuntimeException e) {
+        state.failure = e;
+        throw e;
+      }
+    }
+  }
+
   /** Per-request name→id dedup. Loader must return a non-null id or throw. */
   public Long computeMetadataIdIfAbsent(String key, Function<String, Long> 
loader) {
     return metadataIdCache.computeIfAbsent(
@@ -348,4 +408,8 @@ public class AuthorizationRequestContext {
     private final MetadataObject metadataObject;
     private final Privilege.Name privilege;
   }
+
+  private static class CatalogNormalizationState {
+    @Nullable private RuntimeException failure;
+  }
 }
diff --git 
a/core/src/main/java/org/apache/gravitino/utils/EntityClassMapper.java 
b/core/src/main/java/org/apache/gravitino/utils/EntityClassMapper.java
index 0a503e518c..c2ac21e80f 100644
--- a/core/src/main/java/org/apache/gravitino/utils/EntityClassMapper.java
+++ b/core/src/main/java/org/apache/gravitino/utils/EntityClassMapper.java
@@ -27,6 +27,7 @@ import org.apache.gravitino.meta.BaseMetalake;
 import org.apache.gravitino.meta.CatalogEntity;
 import org.apache.gravitino.meta.ColumnEntity;
 import org.apache.gravitino.meta.FilesetEntity;
+import org.apache.gravitino.meta.FunctionEntity;
 import org.apache.gravitino.meta.GroupEntity;
 import org.apache.gravitino.meta.JobEntity;
 import org.apache.gravitino.meta.JobTemplateEntity;
@@ -40,6 +41,7 @@ import org.apache.gravitino.meta.TableEntity;
 import org.apache.gravitino.meta.TagEntity;
 import org.apache.gravitino.meta.TopicEntity;
 import org.apache.gravitino.meta.UserEntity;
+import org.apache.gravitino.meta.ViewEntity;
 
 /**
  * Utility class that provides mapping between entity types and their 
corresponding entity classes.
@@ -53,6 +55,8 @@ public class EntityClassMapper {
           .put(Entity.EntityType.CATALOG, CatalogEntity.class)
           .put(Entity.EntityType.SCHEMA, SchemaEntity.class)
           .put(Entity.EntityType.TABLE, TableEntity.class)
+          .put(Entity.EntityType.VIEW, ViewEntity.class)
+          .put(Entity.EntityType.FUNCTION, FunctionEntity.class)
           .put(Entity.EntityType.FILESET, FilesetEntity.class)
           .put(Entity.EntityType.MODEL, ModelEntity.class)
           .put(Entity.EntityType.TOPIC, TopicEntity.class)
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 755e10e97d..6e8437d957 100644
--- 
a/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationRequestContext.java
+++ 
b/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationRequestContext.java
@@ -29,11 +29,20 @@ import java.util.HashSet;
 import java.util.List;
 import java.util.Optional;
 import java.util.Set;
+import java.util.concurrent.Callable;
 import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 import java.util.concurrent.atomic.AtomicInteger;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.MetadataObjects;
+import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.UserPrincipal;
 import org.apache.gravitino.auth.ActiveRoles;
+import org.apache.gravitino.exceptions.NoSuchCatalogException;
 import org.apache.gravitino.storage.relational.po.auth.GroupUpdatedAt;
 import org.apache.gravitino.storage.relational.po.auth.OwnerInfo;
 import org.apache.gravitino.storage.relational.po.auth.UserUpdatedAt;
@@ -233,6 +242,137 @@ public class TestAuthorizationRequestContext {
     assertEquals(2, loaderCalls.get(), "Loader must fire once per distinct 
group key");
   }
 
+  @Test
+  public void testNormalizationDeduplicatesConcurrentLoaders() throws 
Exception {
+    AuthorizationRequestContext context = new AuthorizationRequestContext();
+    MetadataObject object = MetadataObjects.parse("cat.schema.table", 
MetadataObject.Type.TABLE);
+    AtomicInteger calls = new AtomicInteger();
+    CountDownLatch entered = new CountDownLatch(1);
+    CountDownLatch release = new CountDownLatch(1);
+    ExecutorService workers = Executors.newFixedThreadPool(2);
+    try {
+      Callable<MetadataObject> lookup =
+          () ->
+              context.computeNormalizedMetadataObjectIfAbsent(
+                  "ml::table",
+                  NameIdentifier.of("ml", "cat"),
+                  key -> {
+                    calls.incrementAndGet();
+                    entered.countDown();
+                    try {
+                      assertTrue(release.await(5, TimeUnit.SECONDS));
+                    } catch (InterruptedException e) {
+                      Thread.currentThread().interrupt();
+                      throw new IllegalStateException(e);
+                    }
+                    return object;
+                  });
+      Future<MetadataObject> first = workers.submit(lookup);
+      assertTrue(entered.await(5, TimeUnit.SECONDS));
+      // The second lookup starts while the first is still normalizing, so it 
must wait for it.
+      Future<MetadataObject> second = workers.submit(lookup);
+      assertThrows(TimeoutException.class, () -> second.get(200, 
TimeUnit.MILLISECONDS));
+      release.countDown();
+      assertEquals(object, first.get(5, TimeUnit.SECONDS));
+      assertEquals(object, second.get(5, TimeUnit.SECONDS));
+      assertEquals(1, calls.get());
+    } finally {
+      workers.shutdownNow();
+    }
+  }
+
+  /** Verifies that concurrent objects share one failed catalog initialization 
per request. */
+  @Test
+  public void testConcurrentCatalogFailuresAreRememberedAcrossObjects() throws 
Exception {
+    AuthorizationRequestContext context = new AuthorizationRequestContext();
+    NameIdentifier catalog = NameIdentifier.of("ml", "cat");
+    AtomicInteger calls = new AtomicInteger();
+    CountDownLatch entered = new CountDownLatch(1);
+    CountDownLatch release = new CountDownLatch(1);
+    ExecutorService workers = Executors.newFixedThreadPool(2);
+    try {
+      Future<?> first =
+          workers.submit(
+              () ->
+                  assertThrows(
+                      IllegalStateException.class,
+                      () ->
+                          context.computeNormalizedMetadataObjectIfAbsent(
+                              "table1",
+                              catalog,
+                              key -> {
+                                calls.incrementAndGet();
+                                entered.countDown();
+                                try {
+                                  assertTrue(release.await(5, 
TimeUnit.SECONDS));
+                                } catch (InterruptedException e) {
+                                  Thread.currentThread().interrupt();
+                                  throw new IllegalStateException(e);
+                                }
+                                throw new IllegalStateException("Unavailable");
+                              })));
+      assertTrue(entered.await(5, TimeUnit.SECONDS));
+      Future<?> second =
+          workers.submit(
+              () ->
+                  assertThrows(
+                      IllegalStateException.class,
+                      () ->
+                          context.computeNormalizedMetadataObjectIfAbsent(
+                              "table2",
+                              catalog,
+                              key -> {
+                                calls.incrementAndGet();
+                                throw new IllegalStateException("Unavailable");
+                              })));
+      assertThrows(TimeoutException.class, () -> second.get(200, 
TimeUnit.MILLISECONDS));
+      release.countDown();
+      first.get(5, TimeUnit.SECONDS);
+      second.get(5, TimeUnit.SECONDS);
+      assertEquals(1, calls.get());
+    } finally {
+      release.countDown();
+      workers.shutdownNow();
+    }
+  }
+
+  /** Verifies that missing catalogs retain the existing retry behavior. */
+  @Test
+  public void testMissingCatalogDoesNotPreventRetry() {
+    AuthorizationRequestContext context = new AuthorizationRequestContext();
+    NameIdentifier catalog = NameIdentifier.of("ml", "cat");
+    MetadataObject object = MetadataObjects.parse("cat.schema.table", 
MetadataObject.Type.TABLE);
+    assertThrows(
+        NoSuchCatalogException.class,
+        () ->
+            context.computeNormalizedMetadataObjectIfAbsent(
+                "key",
+                catalog,
+                key -> {
+                  throw new NoSuchCatalogException("Missing");
+                }));
+    assertEquals(
+        object, context.computeNormalizedMetadataObjectIfAbsent("key", 
catalog, key -> object));
+  }
+
+  @Test
+  public void testNormalizationDoesNotCacheFailuresOrNull() {
+    AuthorizationRequestContext context = new AuthorizationRequestContext();
+    assertThrows(
+        IllegalStateException.class,
+        () ->
+            context.computeNormalizedMetadataObjectIfAbsent(
+                "key",
+                key -> {
+                  throw new IllegalStateException("Unavailable");
+                }));
+    assertThrows(
+        NullPointerException.class,
+        () -> context.computeNormalizedMetadataObjectIfAbsent("key", key -> 
null));
+    MetadataObject object = MetadataObjects.parse("cat.schema.table", 
MetadataObject.Type.TABLE);
+    assertEquals(object, 
context.computeNormalizedMetadataObjectIfAbsent("key", key -> object));
+  }
+
   @Test
   public void testComputeMetadataIdIfAbsentDedupesLoaderInvocation() {
     AuthorizationRequestContext context = new AuthorizationRequestContext();
diff --git 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/MetadataIdConverter.java
 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/MetadataIdConverter.java
index aace2c1063..5a4f44bb59 100644
--- 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/MetadataIdConverter.java
+++ 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/MetadataIdConverter.java
@@ -22,15 +22,18 @@ package org.apache.gravitino.server.authorization;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ObjectArrays;
 import java.io.IOException;
 import java.util.Map;
 import java.util.Optional;
+import java.util.Set;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.EntityStore;
 import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.HasIdentifier;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.Namespace;
 import org.apache.gravitino.catalog.CapabilityHelpers;
 import org.apache.gravitino.catalog.CatalogManager;
 import org.apache.gravitino.connector.capability.Capability;
@@ -38,6 +41,7 @@ import org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.exceptions.NotFoundException;
 import org.apache.gravitino.utils.EntityClassMapper;
 import org.apache.gravitino.utils.MetadataObjectUtil;
+import org.apache.gravitino.utils.NameIdentifierUtil;
 
 /** It is used to convert MetadataObject to MetadataId */
 public class MetadataIdConverter {
@@ -47,11 +51,13 @@ public class MetadataIdConverter {
       ImmutableMap.of(
           MetadataObject.Type.SCHEMA, Capability.Scope.SCHEMA,
           MetadataObject.Type.TABLE, Capability.Scope.TABLE,
+          MetadataObject.Type.VIEW, Capability.Scope.VIEW,
           MetadataObject.Type.MODEL, Capability.Scope.MODEL,
           MetadataObject.Type.FILESET, Capability.Scope.FILESET,
           MetadataObject.Type.TOPIC, Capability.Scope.TOPIC,
           MetadataObject.Type.COLUMN, Capability.Scope.COLUMN,
-          MetadataObject.Type.SEMANTIC_MODEL, Capability.Scope.SEMANTIC_MODEL);
+          MetadataObject.Type.SEMANTIC_MODEL, Capability.Scope.SEMANTIC_MODEL,
+          MetadataObject.Type.FUNCTION, Capability.Scope.FUNCTION);
 
   private MetadataIdConverter() {}
 
@@ -64,7 +70,6 @@ public class MetadataIdConverter {
    */
   public static Optional<Long> getID(MetadataObject metadataObject, String 
metalake) {
     Preconditions.checkArgument(metadataObject != null, "Metadata object 
cannot be null");
-    EntityStore entityStore = GravitinoEnv.getInstance().entityStore();
     CatalogManager catalogManager = 
GravitinoEnv.getInstance().catalogManager();
 
     MetadataObject.Type metadataType = metadataObject.type();
@@ -78,31 +83,86 @@ public class MetadataIdConverter {
       return Optional.empty();
     }
 
-    Entity.EntityType entityType = 
MetadataObjectUtil.toEntityType(metadataType);
+    return loadId(metadataObject, normalizedIdent);
+  }
 
-    Entity entity;
-    try {
-      entity =
-          entityStore.get(
-              normalizedIdent, entityType, 
EntityClassMapper.getEntityClass(entityType));
-    } catch (NoSuchEntityException nse) {
-      return Optional.empty();
-    } catch (IOException e) {
-      throw new RuntimeException(
-          "failed to load entity from entity store: " + 
metadataObject.fullName(), e);
+  /**
+   * Loads an ID using an object already normalized by {@link 
#normalizeMetadataObject}.
+   *
+   * <p>The caller must use the same normalized object for the cache key and 
this lookup. This
+   * method does not resolve catalog capabilities or normalize the name again.
+   *
+   * @param metadataObject the previously normalized metadata object
+   * @param metalake the metalake name
+   * @return the metadata ID, or empty if the entity does not exist
+   * @throws RuntimeException if the entity store cannot load the entity
+   */
+  public static Optional<Long> getIdForNormalizedObject(
+      MetadataObject metadataObject, String metalake) {
+    Preconditions.checkArgument(metadataObject != null, "Metadata object 
cannot be null");
+    return loadId(metadataObject, MetadataObjectUtil.toEntityIdent(metalake, 
metadataObject));
+  }
+
+  /**
+   * Normalizes a metadata object's name using the same catalog rules as ID 
resolution.
+   *
+   * <p>Types without a catalog capability scope retain their names. Semantic 
model leaves remain
+   * case sensitive, while column names and their table/schema parents follow 
their own scopes.
+   *
+   * @param metadataObject the object whose name will be normalized
+   * @param metalake the metalake name
+   * @return the normalized object, suitable for name-to-ID cache keys
+   * @throws NotFoundException if the containing catalog does not exist
+   * @throws RuntimeException if catalog capabilities cannot be resolved
+   */
+  public static MetadataObject normalizeMetadataObject(
+      MetadataObject metadataObject, String metalake) {
+    Preconditions.checkArgument(metadataObject != null, "Metadata object 
cannot be null");
+    Capability.Scope scope = METADATA_SCOPE_MAPPING.get(metadataObject.type());
+    if (scope == null) {
+      return metadataObject;
+    }
+    NameIdentifier ident = MetadataObjectUtil.toEntityIdent(metalake, 
metadataObject);
+    NameIdentifier normalized =
+        normalizeIdentifier(ident, scope, 
GravitinoEnv.getInstance().catalogManager());
+    if (normalized.equals(ident)) {
+      return metadataObject;
     }
+    return NameIdentifierUtil.toMetadataObject(
+        normalized, MetadataObjectUtil.toEntityType(metadataObject));
+  }
 
-    return Optional.of(extractIdFromEntity(entity));
+  /**
+   * Returns the immutable set of metadata types with catalog-scoped 
normalization rules.
+   *
+   * @return the types covered by the production capability mapping
+   */
+  public static Set<MetadataObject.Type> catalogScopedTypes() {
+    return METADATA_SCOPE_MAPPING.keySet();
   }
 
   @VisibleForTesting
   static NameIdentifier normalizeCaseSensitive(
       NameIdentifier ident, Capability.Scope scope, CatalogManager 
catalogManager) {
+    return normalizeIdentifier(ident, scope, catalogManager);
+  }
+
+  private static NameIdentifier normalizeIdentifier(
+      NameIdentifier ident, Capability.Scope scope, CatalogManager 
catalogManager) {
     if (scope == null) {
       return ident;
     }
 
     Capability capability = CapabilityHelpers.getCapability(ident, 
catalogManager);
+    if (scope == Capability.Scope.COLUMN) {
+      // The NameIdentifier overload applies SCHEMA to the namespace and TABLE 
to the leaf.
+      NameIdentifier table =
+          CapabilityHelpers.applyCaseSensitive(
+              NameIdentifier.of(ident.namespace().levels()), 
Capability.Scope.TABLE, capability);
+      return NameIdentifier.of(
+          Namespace.of(ObjectArrays.concat(table.namespace().levels(), 
table.name())),
+          CapabilityHelpers.applyCaseSensitiveOnName(scope, ident.name(), 
capability));
+    }
 
     if (scope == Capability.Scope.SEMANTIC_MODEL) {
       return NameIdentifier.of(
@@ -121,6 +181,21 @@ public class MetadataIdConverter {
     return normalized;
   }
 
+  private static Optional<Long> loadId(MetadataObject metadataObject, 
NameIdentifier ident) {
+    EntityStore entityStore = GravitinoEnv.getInstance().entityStore();
+    Entity.EntityType entityType = 
MetadataObjectUtil.toEntityType(metadataObject.type());
+    Entity entity;
+    try {
+      entity = entityStore.get(ident, entityType, 
EntityClassMapper.getEntityClass(entityType));
+    } catch (NoSuchEntityException e) {
+      return Optional.empty();
+    } catch (IOException e) {
+      throw new RuntimeException(
+          "failed to load entity from entity store: " + 
metadataObject.fullName(), e);
+    }
+    return Optional.of(extractIdFromEntity(entity));
+  }
+
   private static Long extractIdFromEntity(Entity entity) {
     Preconditions.checkArgument(
         entity instanceof HasIdentifier, "Entity must implement HasIdentifier 
interface");
diff --git 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizationLookups.java
 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizationLookups.java
index e084013f24..a831711c71 100644
--- 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizationLookups.java
+++ 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizationLookups.java
@@ -19,14 +19,11 @@
 package org.apache.gravitino.server.authorization.jcasbin;
 
 import java.util.Optional;
-import org.apache.gravitino.Entity;
-import org.apache.gravitino.GravitinoEnv;
+import java.util.function.Function;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.authorization.AuthorizationRequestContext;
 import org.apache.gravitino.cache.GravitinoCache;
-import org.apache.gravitino.catalog.CapabilityHelpers;
-import org.apache.gravitino.connector.capability.Capability;
 import org.apache.gravitino.exceptions.NoSuchMetadataObjectException;
 import org.apache.gravitino.exceptions.NotFoundException;
 import org.apache.gravitino.server.authorization.MetadataIdConverter;
@@ -35,6 +32,8 @@ import 
org.apache.gravitino.storage.relational.po.auth.OwnerInfo;
 import org.apache.gravitino.storage.relational.utils.SessionUtils;
 import org.apache.gravitino.utils.MetadataObjectUtil;
 import org.apache.gravitino.utils.NameIdentifierUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * Two-tier metadata-id and owner resolution for {@link JcasbinAuthorizer}.
@@ -50,6 +49,8 @@ import org.apache.gravitino.utils.NameIdentifierUtil;
  */
 public class JcasbinAuthorizationLookups {
 
+  private static final Logger LOG = 
LoggerFactory.getLogger(JcasbinAuthorizationLookups.class);
+
   private final GravitinoCache<String, Long> metadataIdCache;
   private final GravitinoCache<Long, Optional<OwnerInfo>> ownerRelCache;
 
@@ -70,49 +71,19 @@ public class JcasbinAuthorizationLookups {
   /**
    * Two-tier name→id lookup: the per-request map in {@code requestContext} 
dedups calls within the
    * same HTTP request; on a miss, the long-lived {@code metadataIdCache} is 
consulted, and finally
-   * we fall back to a DB query via {@link MetadataIdConverter#getID}. Returns 
{@link
-   * Optional#empty()} when the metadata object does not exist so callers can 
deny authorization.
-   * Missing metadata objects are never cached as negative results: a later 
create for the same name
-   * can be observed without waiting for cache eviction. Existing objects are 
invalidated by local
-   * name-id mapping hooks and by the change-log poller on peer nodes.
+   * we fall back to a DB query via {@link 
MetadataIdConverter#getIdForNormalizedObject}. Returns
+   * {@link Optional#empty()} when the metadata object does not exist or 
normalization fails so
+   * callers can deny authorization. Missing metadata objects are never cached 
as negative results:
+   * a later create for the same name can be observed without waiting for 
cache eviction. Existing
+   * objects are invalidated by local name-id mapping hooks and by the 
change-log poller on peer
+   * nodes. Both cache tiers use names normalized by catalog capability. 
Normalization is
+   * deduplicated per raw name within a request; a fresh request still 
resolves current catalog
+   * rules before consulting the shared cache. Capability failures are 
remembered per catalog within
+   * this request, so a failing connector is attempted and logged only once.
    */
   public Optional<Long> resolveMetadataId(
       MetadataObject metadataObject, String metalake, 
AuthorizationRequestContext requestContext) {
-    try {
-      MetadataObject cacheObject = metadataObject;
-      // Hooks and change-log records use normalized parents. Cache the same 
key for every
-      // equivalent request spelling, while preserving the Gravitino-owned 
model leaf.
-      if (metadataObject.type() == MetadataObject.Type.SEMANTIC_MODEL) {
-        NameIdentifier ident = MetadataObjectUtil.toEntityIdent(metalake, 
metadataObject);
-        Capability capability =
-            CapabilityHelpers.getCapability(ident, 
GravitinoEnv.getInstance().catalogManager());
-        NameIdentifier normalized =
-            NameIdentifier.of(
-                CapabilityHelpers.applyCaseSensitive(
-                    ident.namespace(), Capability.Scope.SEMANTIC_MODEL, 
capability),
-                ident.name());
-        cacheObject =
-            NameIdentifierUtil.toMetadataObject(normalized, 
Entity.EntityType.SEMANTIC_MODEL);
-      }
-      String cacheKey = 
JcasbinAuthorizationCacheKeys.metadataIdCacheKey(metalake, cacheObject);
-      // Both cache tiers load atomically and forbid caching null, so a 
missing object is signalled
-      // by throwing through the loaders and translated back to 
Optional.empty() here. This caches
-      // only positive results, never a negative one.
-      return Optional.of(
-          requestContext.computeMetadataIdIfAbsent(
-              cacheKey,
-              k -> metadataIdCache.get(k, ignored -> 
loadMetadataId(metadataObject, metalake))));
-    } catch (NotFoundException e) {
-      return Optional.empty();
-    }
-  }
-
-  private static Long loadMetadataId(MetadataObject metadataObject, String 
metalake) {
-    return MetadataIdConverter.getID(metadataObject, metalake)
-        .orElseThrow(
-            () ->
-                new NoSuchMetadataObjectException(
-                    "Metadata object %s does not exist", 
metadataObject.fullName()));
+    return resolveMetadataIdResult(metadataObject, metalake, 
requestContext).metadataId();
   }
 
   /**
@@ -131,6 +102,73 @@ public class JcasbinAuthorizationLookups {
         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.
+      String rawKey = 
JcasbinAuthorizationCacheKeys.metadataIdCacheKey(metalake, metadataObject);
+      Function<String, MetadataObject> normalizer =
+          ignored -> {
+            try {
+              return 
MetadataIdConverter.normalizeMetadataObject(metadataObject, metalake);
+            } catch (NotFoundException e) {
+              throw e;
+            } catch (RuntimeException e) {
+              LOG.warn(
+                  "Cannot normalize metadata object {}:{} in metalake {}; 
authorization lookup is unresolved",
+                  metadataObject.type(),
+                  metadataObject.fullName(),
+                  metalake,
+                  e);
+              throw e;
+            }
+          };
+      if 
(MetadataIdConverter.catalogScopedTypes().contains(metadataObject.type())) {
+        NameIdentifier catalogIdentifier =
+            NameIdentifierUtil.getCatalogIdentifier(
+                MetadataObjectUtil.toEntityIdent(metalake, metadataObject));
+        cacheObject =
+            requestContext.computeNormalizedMetadataObjectIfAbsent(
+                rawKey, catalogIdentifier, normalizer);
+      } else {
+        // Catalog ID resolution must remain available to install conservative 
DENY guards.
+        cacheObject = 
requestContext.computeNormalizedMetadataObjectIfAbsent(rawKey, normalizer);
+      }
+    } catch (NotFoundException e) {
+      return new MetadataIdResolution(Optional.empty(), false);
+    } catch (RuntimeException e) {
+      // Never use a raw key after normalization fails. Only this request 
remembers the failure;
+      // entity-store and cache-loader failures below still propagate.
+      return new MetadataIdResolution(Optional.empty(), true);
+    }
+    String cacheKey = 
JcasbinAuthorizationCacheKeys.metadataIdCacheKey(metalake, cacheObject);
+    try {
+      // Both cache tiers load atomically and forbid caching null, so a 
missing object is signalled
+      // by throwing through the loaders and translated back to 
Optional.empty() here. This caches
+      // only positive results, never a negative one. Load the same canonical 
name as the key,
+      // rather than applying the request spelling again in the loader.
+      return new MetadataIdResolution(
+          Optional.of(
+              requestContext.computeMetadataIdIfAbsent(
+                  cacheKey,
+                  k -> metadataIdCache.get(k, ignored -> 
loadMetadataId(cacheObject, metalake)))),
+          false);
+    } catch (NotFoundException e) {
+      return new MetadataIdResolution(Optional.empty(), false);
+    }
+  }
+
+  private static Long loadMetadataId(MetadataObject metadataObject, String 
metalake) {
+    return MetadataIdConverter.getIdForNormalizedObject(metadataObject, 
metalake)
+        .orElseThrow(
+            () ->
+                new NoSuchMetadataObjectException(
+                    "Metadata object %s does not exist", 
metadataObject.fullName()));
+  }
+
   private static Optional<OwnerInfo> loadOwner(Long id, MetadataObject.Type 
metadataType) {
     OwnerInfo ownerInfo =
         SessionUtils.getWithoutCommit(
@@ -138,4 +176,25 @@ public class JcasbinAuthorizationLookups {
             m -> m.selectOwnerByMetadataObjectIdAndType(id, 
metadataType.name()));
     return Optional.ofNullable(ownerInfo);
   }
+
+  /** An ID lookup outcome, retaining capability failures for conservative 
DENY evaluation. */
+  static final class MetadataIdResolution {
+    private final Optional<Long> metadataId;
+    private final boolean normalizationFailed;
+
+    MetadataIdResolution(Optional<Long> metadataId, boolean 
normalizationFailed) {
+      this.metadataId = metadataId;
+      this.normalizationFailed = normalizationFailed;
+    }
+
+    /** Returns the metadata ID, or empty if the object is missing or could 
not be normalized. */
+    Optional<Long> metadataId() {
+      return metadataId;
+    }
+
+    /** Returns whether the catalog rules needed to normalize the object could 
not be resolved. */
+    boolean normalizationFailed() {
+      return normalizationFailed;
+    }
+  }
 }
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 9e67e07389..4d0e4a0d94 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
@@ -76,6 +76,7 @@ import 
org.apache.gravitino.storage.relational.po.auth.RoleUpdatedAt;
 import org.apache.gravitino.storage.relational.po.auth.UserUpdatedAt;
 import org.apache.gravitino.storage.relational.utils.SessionUtils;
 import org.apache.gravitino.utils.HierarchicalSchemaUtil;
+import org.apache.gravitino.utils.MetadataObjectUtil;
 import org.apache.gravitino.utils.NameIdentifierUtil;
 import org.apache.gravitino.utils.PrincipalUtils;
 import org.casbin.jcasbin.main.Enforcer;
@@ -872,8 +873,9 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
 
     /**
      * 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.
+     * duration of a check, or when the checked object's name cannot be 
normalized: {@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;
 
@@ -967,8 +969,14 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
         MetadataObject metadataObject,
         String privilege,
         AuthorizationRequestContext requestContext) {
-      Optional<Long> metadataId =
-          lookups.resolveMetadataId(metadataObject, metalake, requestContext);
+      JcasbinAuthorizationLookups.MetadataIdResolution resolution =
+          lookups.resolveMetadataIdResult(metadataObject, metalake, 
requestContext);
+      if (resolution.normalizationFailed()) {
+        // Failure to resolve a DENY scope must not let a parent ALLOW grant 
access, even when
+        // the role's policies are already loaded. Missing entities retain 
their existing semantics.
+        return unstablePolicyResult;
+      }
+      Optional<Long> metadataId = resolution.metadataId();
       if (!metadataId.isPresent()) {
         return false;
       }
@@ -1532,7 +1540,12 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
 
       // Resolve every securable object to a metadata id before touching the 
enforcers, so the
       // critical section below stays free of DB round-trips.
-      ResolvedRolePolicies resolved = resolveRolePolicies(roleEntity, 
requestContext);
+      Optional<ResolvedRolePolicies> resolution = 
resolveRolePolicies(roleEntity, requestContext);
+      if (!resolution.isPresent()) {
+        unreadableRoleIds.add(roleId);
+        continue;
+      }
+      ResolvedRolePolicies resolved = resolution.get();
 
       if (!replaceRolePolicies(roleId, dbUpdatedAt, resolved)) {
         // Another request completed this version (or a newer one) while this 
request was resolving
@@ -1846,11 +1859,13 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
    *
    * <p>A securable object that cannot be resolved to a metadata id is 
reported in {@link
    * ResolvedRolePolicies#getUnresolvedObjects()} rather than silently 
dropped. That normally means
-   * the object has been dropped while the role still references it, but it is 
indistinguishable
-   * from a transient lookup failure, and the two must not be conflated: the 
caller relies on this
-   * flag to decide whether the resulting enforcer state is complete enough to 
be cached.
+   * the object has been dropped while the role still references it. Transient 
normalization
+   * failures also leave the role incomplete, but require a conservative 
catalog-scoped guard for
+   * DENY privileges. If that guard cannot be installed, returns empty so the 
caller marks the role
+   * unreadable without publishing any of its policies. The caller records a 
loaded marker only when
+   * all objects were resolved.
    */
-  private ResolvedRolePolicies resolveRolePolicies(
+  private Optional<ResolvedRolePolicies> resolveRolePolicies(
       RoleEntity roleEntity, AuthorizationRequestContext requestContext) {
     String metalake = 
NameIdentifierUtil.getMetalake(roleEntity.nameIdentifier());
     String roleIdStr = String.valueOf(roleEntity.id());
@@ -1861,37 +1876,94 @@ public class JcasbinAuthorizer implements 
GravitinoAuthorizer {
     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)) {
+          return Optional.empty();
+        }
         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 Optional.of(new ResolvedRolePolicies(allowRows, denyRows, 
unresolvedObjects));
+  }
 
-        allowRows.add(
+  private boolean 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 true;
+    }
+    // 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. Mark the role unreadable and 
publish none of
+      // its policies; the next request retries.
+      LOG.warn("Cannot resolve catalog for unresolved deny policy on {}", 
object.fullName());
+      return false;
+    }
+    addPolicyRows(
+        roleId, MetadataObject.Type.CATALOG, catalogId.get(), denies, 
allowRows, denyRows);
+    return true;
+  }
+
+  private static void addPolicyRows(
+      String roleId,
+      MetadataObject.Type type,
+      long metadataId,
+      List<Privilege> privileges,
+      List<String[]> allowRows,
+      List<String[]> denyRows) {
+    for (Privilege privilege : privileges) {
+      String action =
+          AuthorizationUtils.replaceLegacyPrivilegeName(privilege.name())
+              .name()
+              .toUpperCase(Locale.ROOT);
+      if (privilege.condition() == Privilege.Condition.DENY) {
+        denyRows.add(
             new String[] {
-              roleIdStr,
-              securableObject.type().name(),
-              metadataIdStr,
-              action,
-              condition.name().toLowerCase(Locale.ROOT)
+              roleId, type.name(), String.valueOf(metadataId), action, 
AuthConstants.ALLOW
             });
       }
+      allowRows.add(
+          new String[] {
+            roleId,
+            type.name(),
+            String.valueOf(metadataId),
+            action,
+            privilege.condition().name().toLowerCase(Locale.ROOT)
+          });
     }
-    return new ResolvedRolePolicies(allowRows, denyRows, unresolvedObjects);
   }
 
   /**
diff --git 
a/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizationLookups.java
 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizationLookups.java
index 6024c457c1..7690694059 100644
--- 
a/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizationLookups.java
+++ 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizationLookups.java
@@ -19,6 +19,7 @@
 package org.apache.gravitino.server.authorization.jcasbin;
 
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mockStatic;
 import static org.mockito.Mockito.times;
@@ -28,14 +29,20 @@ import static org.mockito.Mockito.when;
 import java.util.Arrays;
 import java.util.Optional;
 import java.util.function.Function;
+import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.MetadataObjects;
 import org.apache.gravitino.authorization.AuthorizationRequestContext;
 import org.apache.gravitino.cache.CaffeineGravitinoCache;
 import org.apache.gravitino.cache.GravitinoCache;
+import org.apache.gravitino.catalog.CatalogManager;
+import org.apache.gravitino.connector.BaseCatalog;
+import org.apache.gravitino.connector.capability.Capability;
+import org.apache.gravitino.connector.capability.CapabilityResult;
 import org.apache.gravitino.storage.relational.mapper.OwnerMetaMapper;
 import org.apache.gravitino.storage.relational.po.auth.OwnerInfo;
 import org.apache.gravitino.storage.relational.utils.SessionUtils;
+import org.apache.gravitino.utils.ThrowableFunction;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 import org.mockito.MockedStatic;
@@ -45,8 +52,7 @@ public class TestJcasbinAuthorizationLookups {
 
   @Test
   void testResolveMetadataIdUsesAtomicSharedCacheAndRequestDedup() {
-    MetadataObject table =
-        MetadataObjects.of(Arrays.asList("cat1", "sch1", "tbl1"), 
MetadataObject.Type.TABLE);
+    MetadataObject catalog = MetadataObjects.of(Arrays.asList("cat1"), 
MetadataObject.Type.CATALOG);
     CountingCache<String, Long> metadataIdCache = new CountingCache<>(100L);
     CountingCache<Long, Optional<OwnerInfo>> ownerRelCache = new 
CountingCache<>();
     JcasbinAuthorizationLookups lookups =
@@ -54,15 +60,62 @@ public class TestJcasbinAuthorizationLookups {
     AuthorizationRequestContext requestContext = new 
AuthorizationRequestContext();
 
     Assertions.assertEquals(
-        Optional.of(100L), lookups.resolveMetadataId(table, "ml1", 
requestContext));
+        Optional.of(100L), lookups.resolveMetadataId(catalog, "ml1", 
requestContext));
     Assertions.assertEquals(
-        Optional.of(100L), lookups.resolveMetadataId(table, "ml1", 
requestContext));
+        Optional.of(100L), lookups.resolveMetadataId(catalog, "ml1", 
requestContext));
 
     Assertions.assertEquals(1, metadataIdCache.getCount);
     Assertions.assertEquals(0, metadataIdCache.getIfPresentCount);
     Assertions.assertEquals(0, metadataIdCache.putCount);
   }
 
+  @Test
+  void testNormalizedTableUsesAtomicSharedCacheAndRequestDedup() {
+    MetadataObject alias = MetadataObjects.parse("cat1.SCH1.TBL1", 
MetadataObject.Type.TABLE);
+    MetadataObject canonical = MetadataObjects.parse("cat1.sch1.tbl1", 
MetadataObject.Type.TABLE);
+    CountingCache<String, Long> metadataIdCache = new CountingCache<>(100L);
+    CountingCache<Long, Optional<OwnerInfo>> ownerRelCache = new 
CountingCache<>();
+    JcasbinAuthorizationLookups lookups =
+        new JcasbinAuthorizationLookups(metadataIdCache, ownerRelCache);
+    AuthorizationRequestContext context = new AuthorizationRequestContext();
+    Capability capability =
+        new Capability() {
+          @Override
+          public CapabilityResult caseSensitiveOnName(Scope scope) {
+            return CapabilityResult.unsupported("case insensitive");
+          }
+        };
+    CatalogManager catalogs = mock(CatalogManager.class);
+    BaseCatalog<?> catalog = mock(BaseCatalog.class);
+    when(catalog.capability()).thenReturn(capability);
+    doAnswer(
+            invocation -> {
+              ThrowableFunction<BaseCatalog<?>, Object> operation = 
invocation.getArgument(1);
+              return operation.apply(catalog);
+            })
+        .when(catalogs)
+        .doWithCatalog(any(), any());
+    GravitinoEnv env = mock(GravitinoEnv.class);
+    when(env.catalogManager()).thenReturn(catalogs);
+    try (MockedStatic<GravitinoEnv> envMock = mockStatic(GravitinoEnv.class)) {
+      envMock.when(GravitinoEnv::getInstance).thenReturn(env);
+      Assertions.assertEquals(Optional.of(100L), 
lookups.resolveMetadataId(alias, "ml1", context));
+      Assertions.assertEquals(Optional.of(100L), 
lookups.resolveMetadataId(alias, "ml1", context));
+      Assertions.assertEquals(
+          Optional.of(100L), lookups.resolveMetadataId(canonical, "ml1", 
context));
+    }
+    // Each distinct spelling resolves capabilities and the physical table 
name once.
+    verify(catalogs, times(4)).doWithCatalog(any(), any());
+    verify(catalog, times(2)).capability();
+    verify(catalog, times(2)).ops();
+    Assertions.assertEquals(
+        JcasbinAuthorizationCacheKeys.metadataIdCacheKey("ml1", canonical),
+        metadataIdCache.lastKey);
+    Assertions.assertEquals(1, metadataIdCache.getCount);
+    Assertions.assertEquals(0, metadataIdCache.getIfPresentCount);
+    Assertions.assertEquals(0, metadataIdCache.putCount);
+  }
+
   @Test
   void testResolveOwnerIdCachesPositiveOwnerInSharedCache() {
     CountingCache<String, Long> metadataIdCache = new CountingCache<>(100L);
@@ -160,6 +213,7 @@ public class TestJcasbinAuthorizationLookups {
   private static class CountingCache<K, V> implements GravitinoCache<K, V> {
     private final V value;
     private Optional<V> cachedValue = Optional.empty();
+    private K lastKey;
     private int getCount;
     private int getIfPresentCount;
     private int putCount;
@@ -181,6 +235,7 @@ public class TestJcasbinAuthorizationLookups {
     @Override
     public V get(K key, Function<K, V> loader) {
       getCount++;
+      lastKey = key;
       if (cachedValue.isPresent()) {
         return cachedValue.get();
       }
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 3bd54a64d6..ad84591415 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
@@ -42,6 +42,7 @@ import static org.mockito.Mockito.when;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import java.io.IOException;
 import java.lang.reflect.Field;
@@ -104,6 +105,7 @@ import org.apache.gravitino.semantic.SemanticModelChange;
 import org.apache.gravitino.server.ServerConfig;
 import org.apache.gravitino.server.authorization.AuthorizationRequestScope;
 import org.apache.gravitino.server.authorization.MetadataIdConverter;
+import 
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionEvaluator;
 import org.apache.gravitino.storage.relational.mapper.EntityChangeLogMapper;
 import org.apache.gravitino.storage.relational.mapper.GroupMetaMapper;
 import org.apache.gravitino.storage.relational.mapper.OwnerMetaMapper;
@@ -314,7 +316,21 @@ public class TestJcasbinAuthorizer {
     
gravitinoEnvMockedStatic.when(GravitinoEnv::getInstance).thenReturn(gravitinoEnv);
     when(gravitinoEnv.config()).thenReturn(new ServerConfig());
     principalUtilsMockedStatic = mockStatic(PrincipalUtils.class);
-    metadataIdConverterMockedStatic = mockStatic(MetadataIdConverter.class);
+    metadataIdConverterMockedStatic =
+        mockStatic(
+            MetadataIdConverter.class,
+            invocation -> {
+              // Keep ID-loading fixtures, but execute all normalization 
helpers (including nested
+              // static calls) so capability rules are tested rather than 
stubbed away.
+              if 
(invocation.getMethod().getName().equals("getIdForNormalizedObject")) {
+                return MetadataIdConverter.getID(
+                    invocation.getArgument(0), invocation.getArgument(1));
+              }
+              if (invocation.getMethod().getName().equals("getID")) {
+                return Optional.empty();
+              }
+              return invocation.callRealMethod();
+            });
     principalUtilsMockedStatic
         .when(PrincipalUtils::getCurrentPrincipal)
         .thenReturn(new UserPrincipal(USERNAME));
@@ -368,6 +384,17 @@ public class TestJcasbinAuthorizer {
   public void createAuthorizer() throws Exception {
     // Build a fresh authorizer per test so enforcer g-rows and 
version-validated cache state can
     // never bleed across cases regardless of the JUnit execution order.
+    CatalogManager catalogs = mock(CatalogManager.class);
+    BaseCatalog<?> catalog = mock(BaseCatalog.class);
+    when(catalog.capability()).thenReturn(Capability.DEFAULT);
+    doAnswer(
+            invocation -> {
+              ThrowableFunction<BaseCatalog<?>, Object> operation = 
invocation.getArgument(1);
+              return operation.apply(catalog);
+            })
+        .when(catalogs)
+        .doWithCatalog(any(), any());
+    when(gravitinoEnv.catalogManager()).thenReturn(catalogs);
     jcasbinAuthorizer = new JcasbinAuthorizer();
     jcasbinAuthorizer.initialize();
     restoreDefaultPrincipal();
@@ -650,6 +677,336 @@ public class TestJcasbinAuthorizer {
         "loadedRoles entry for the deleted role must be evicted");
   }
 
+  /** Verifies that a connector failure does not suppress healthy catalog 
grants. */
+  @Test
+  public void testCapabilityFailureDoesNotAbortHealthyCatalogPolicyLoading() 
throws Exception {
+    CatalogManager catalogs = gravitinoEnv.catalogManager();
+    Mockito.doThrow(new IllegalStateException("Connector initialization 
failed"))
+        .when(catalogs)
+        .doWithCatalog(eq(NameIdentifier.of(METALAKE, "broken")), any());
+    RoleEntity role =
+        mockRoleInStore(
+            ALLOW_ROLE_ID,
+            "mixedCatalogRole",
+            ImmutableList.of(
+                buildSecurableObject(
+                    ALLOW_ROLE_ID,
+                    MetadataObject.Type.TABLE,
+                    "broken.schema.table",
+                    Privilege.Name.SELECT_TABLE,
+                    "ALLOW"),
+                buildSecurableObject(
+                    ALLOW_ROLE_ID,
+                    MetadataObject.Type.TABLE,
+                    "healthy.schema.table",
+                    Privilege.Name.SELECT_TABLE,
+                    "ALLOW")));
+    mockDirectUserRoles(role);
+    Principal principal = PrincipalUtils.getCurrentPrincipal();
+    assertTrue(
+        jcasbinAuthorizer.authorize(
+            principal,
+            METALAKE,
+            MetadataObjects.parse("healthy.schema.table", 
MetadataObject.Type.TABLE),
+            Privilege.Name.SELECT_TABLE,
+            new AuthorizationRequestContext()));
+    assertFalse(
+        jcasbinAuthorizer.authorize(
+            principal,
+            METALAKE,
+            MetadataObjects.parse("broken.schema.table", 
MetadataObject.Type.TABLE),
+            Privilege.Name.SELECT_TABLE,
+            new AuthorizationRequestContext()));
+    
assertFalse(getLoadedRolesCache(jcasbinAuthorizer).getIfPresent(ALLOW_ROLE_ID).isPresent());
+    assertTrue(
+        
getPartialRoleLoadBackoffCache(jcasbinAuthorizer).getIfPresent(ALLOW_ROLE_ID).isPresent());
+  }
+
+  /** Verifies conservative DENY scope, backoff, role narrowing and recovery. 
*/
+  @Test
+  public void 
testUnresolvedDenyGuardsCatalogAndRestoresPreciseScopeAfterRetry() throws 
Exception {
+    MetadataObject denied =
+        MetadataObjects.parse("broken.schema.denied", 
MetadataObject.Type.TABLE);
+    MetadataObject sibling =
+        MetadataObjects.parse("broken.schema.sibling", 
MetadataObject.Type.TABLE);
+    MetadataObject healthy = MetadataObjects.of(null, "healthy", 
MetadataObject.Type.CATALOG);
+    metadataIdConverterMockedStatic
+        .when(() -> MetadataIdConverter.getID(denied, METALAKE))
+        .thenReturn(Optional.of(10L));
+    metadataIdConverterMockedStatic
+        .when(() -> MetadataIdConverter.getID(sibling, METALAKE))
+        .thenReturn(Optional.of(11L));
+    metadataIdConverterMockedStatic
+        .when(() -> MetadataIdConverter.getID(healthy, METALAKE))
+        .thenReturn(Optional.of(20L));
+    RoleEntity allowRole =
+        mockRoleInStore(
+            ALLOW_ROLE_ID,
+            "ancestorAllowRole",
+            ImmutableList.of(
+                buildSecurableObject(
+                    ALLOW_ROLE_ID,
+                    MetadataObject.Type.METALAKE,
+                    METALAKE,
+                    Privilege.Name.SELECT_TABLE,
+                    "ALLOW")));
+    RoleEntity denyRole =
+        mockRoleInStore(
+            DENY_ROLE_ID,
+            "childDenyRole",
+            ImmutableList.of(
+                buildSecurableObject(
+                    DENY_ROLE_ID,
+                    MetadataObject.Type.TABLE,
+                    denied.fullName(),
+                    Privilege.Name.SELECT_TABLE,
+                    "DENY")));
+    mockDirectUserRoles(allowRole, denyRole);
+    CatalogManager catalogs = gravitinoEnv.catalogManager();
+    Mockito.doThrow(new IllegalStateException("Connector initialization 
failed"))
+        .when(catalogs)
+        .doWithCatalog(eq(NameIdentifier.of(METALAKE, "broken")), any());
+    AuthorizationExpressionEvaluator evaluator =
+        new AuthorizationExpressionEvaluator("ANY_SELECT_TABLE", 
jcasbinAuthorizer);
+    try {
+      assertFalse(
+          evaluator.evaluate(
+              tableMetadataNames("broken", "denied"), new 
AuthorizationRequestContext()));
+      // The guard must persist through the partial-role retry backoff and 
role narrowing.
+      AuthorizationRequestContext narrowed = new AuthorizationRequestContext();
+      narrowed.setActiveRoles(ActiveRoles.of(Set.of("ancestorAllowRole")));
+      assertFalse(evaluator.evaluate(tableMetadataNames("broken", "sibling"), 
narrowed));
+      assertTrue(
+          evaluator.evaluate(
+              tableMetadataNames("healthy", "table"), new 
AuthorizationRequestContext()));
+      assertTrue(
+          jcasbinAuthorizer.hasDenyPolicy(
+              PrincipalUtils.getCurrentPrincipal(),
+              METALAKE,
+              Set.of(Privilege.Name.SELECT_TABLE),
+              new AuthorizationRequestContext()));
+      
assertFalse(getLoadedRolesCache(jcasbinAuthorizer).getIfPresent(DENY_ROLE_ID).isPresent());
+
+      BaseCatalog<?> catalog = mock(BaseCatalog.class);
+      when(catalog.capability()).thenReturn(Capability.DEFAULT);
+      doAnswer(
+              invocation -> {
+                ThrowableFunction<BaseCatalog<?>, Object> operation = 
invocation.getArgument(1);
+                return operation.apply(catalog);
+              })
+          .when(catalogs)
+          .doWithCatalog(eq(NameIdentifier.of(METALAKE, "broken")), any());
+      // The catalog has recovered but the role is still in its retry backoff: 
only the guard keeps
+      // the ancestor ALLOW from granting the denied table.
+      assertTrue(
+          
getPartialRoleLoadBackoffCache(jcasbinAuthorizer).getIfPresent(DENY_ROLE_ID).isPresent());
+      assertFalse(
+          evaluator.evaluate(
+              tableMetadataNames("broken", "denied"), new 
AuthorizationRequestContext()));
+      
getPartialRoleLoadBackoffCache(jcasbinAuthorizer).invalidate(DENY_ROLE_ID);
+      assertFalse(
+          evaluator.evaluate(
+              tableMetadataNames("broken", "denied"), new 
AuthorizationRequestContext()));
+      assertTrue(
+          evaluator.evaluate(
+              tableMetadataNames("broken", "sibling"), new 
AuthorizationRequestContext()));
+      
assertTrue(getLoadedRolesCache(jcasbinAuthorizer).getIfPresent(DENY_ROLE_ID).isPresent());
+      assertFalse(
+          getDenyEnforcer(jcasbinAuthorizer)
+              .hasPolicy(
+                  String.valueOf(DENY_ROLE_ID),
+                  "CATALOG",
+                  String.valueOf(CATALOG_ID),
+                  "SELECT_TABLE",
+                  "allow"));
+    } finally {
+      metadataIdConverterMockedStatic
+          .when(() -> MetadataIdConverter.getID(denied, METALAKE))
+          .thenReturn(Optional.of(CATALOG_ID));
+      metadataIdConverterMockedStatic
+          .when(() -> MetadataIdConverter.getID(sibling, METALAKE))
+          .thenReturn(Optional.of(CATALOG_ID));
+      metadataIdConverterMockedStatic
+          .when(() -> MetadataIdConverter.getID(healthy, METALAKE))
+          .thenReturn(Optional.of(CATALOG_ID));
+    }
+  }
+
+  /** Verifies that normalization failure cannot bypass a cached DENY policy. 
*/
+  @Test
+  public void testNormalizationFailureCannotBypassAlreadyLoadedDeny() throws 
Exception {
+    MetadataObject denied =
+        MetadataObjects.parse("broken.schema.denied", 
MetadataObject.Type.TABLE);
+    MetadataObject sibling =
+        MetadataObjects.parse("broken.schema.sibling", 
MetadataObject.Type.TABLE);
+    MetadataObject healthy = MetadataObjects.of(null, "healthy", 
MetadataObject.Type.CATALOG);
+    metadataIdConverterMockedStatic
+        .when(() -> MetadataIdConverter.getID(denied, METALAKE))
+        .thenReturn(Optional.of(10L));
+    metadataIdConverterMockedStatic
+        .when(() -> MetadataIdConverter.getID(sibling, METALAKE))
+        .thenReturn(Optional.of(11L));
+    metadataIdConverterMockedStatic
+        .when(() -> MetadataIdConverter.getID(healthy, METALAKE))
+        .thenReturn(Optional.of(20L));
+    RoleEntity role =
+        mockRoleInStore(
+            ALLOW_ROLE_ID,
+            "completeRole",
+            ImmutableList.of(
+                buildSecurableObject(
+                    ALLOW_ROLE_ID,
+                    MetadataObject.Type.METALAKE,
+                    METALAKE,
+                    Privilege.Name.SELECT_TABLE,
+                    "ALLOW"),
+                buildSecurableObject(
+                    ALLOW_ROLE_ID,
+                    MetadataObject.Type.TABLE,
+                    denied.fullName(),
+                    Privilege.Name.SELECT_TABLE,
+                    "DENY")));
+    mockDirectUserRoles(role);
+    AuthorizationExpressionEvaluator evaluator =
+        new AuthorizationExpressionEvaluator("ANY_SELECT_TABLE", 
jcasbinAuthorizer);
+    try {
+      assertFalse(
+          evaluator.evaluate(
+              tableMetadataNames("broken", "denied"), new 
AuthorizationRequestContext()));
+      assertTrue(
+          evaluator.evaluate(
+              tableMetadataNames("broken", "sibling"), new 
AuthorizationRequestContext()));
+      
assertTrue(getLoadedRolesCache(jcasbinAuthorizer).getIfPresent(ALLOW_ROLE_ID).isPresent());
+      CatalogManager catalogs = gravitinoEnv.catalogManager();
+      Mockito.doThrow(new IllegalStateException("Connector initialization 
failed"))
+          .when(catalogs)
+          .doWithCatalog(eq(NameIdentifier.of(METALAKE, "broken")), any());
+      assertFalse(
+          evaluator.evaluate(
+              tableMetadataNames("broken", "denied"), new 
AuthorizationRequestContext()));
+      assertFalse(
+          evaluator.evaluate(
+              tableMetadataNames("broken", "sibling"), new 
AuthorizationRequestContext()));
+      assertTrue(
+          evaluator.evaluate(
+              tableMetadataNames("healthy", "table"), new 
AuthorizationRequestContext()));
+      
assertTrue(getLoadedRolesCache(jcasbinAuthorizer).getIfPresent(ALLOW_ROLE_ID).isPresent());
+    } finally {
+      metadataIdConverterMockedStatic
+          .when(() -> MetadataIdConverter.getID(denied, METALAKE))
+          .thenReturn(Optional.of(CATALOG_ID));
+      metadataIdConverterMockedStatic
+          .when(() -> MetadataIdConverter.getID(sibling, METALAKE))
+          .thenReturn(Optional.of(CATALOG_ID));
+      metadataIdConverterMockedStatic
+          .when(() -> MetadataIdConverter.getID(healthy, METALAKE))
+          .thenReturn(Optional.of(CATALOG_ID));
+    }
+  }
+
+  /** Verifies that a missing entity does not block inherited creation 
privileges. */
+  @Test
+  public void testMissingEntityStillAllowsInheritedCreatePrivilege() throws 
Exception {
+    MetadataObject missing =
+        MetadataObjects.parse("catalog.schema.newTable", 
MetadataObject.Type.TABLE);
+    RoleEntity role =
+        mockRoleInStore(
+            ALLOW_ROLE_ID,
+            "createRole",
+            ImmutableList.of(
+                buildSecurableObject(
+                    ALLOW_ROLE_ID,
+                    MetadataObject.Type.METALAKE,
+                    METALAKE,
+                    Privilege.Name.CREATE_TABLE,
+                    "ALLOW")));
+    mockDirectUserRoles(role);
+    metadataIdConverterMockedStatic
+        .when(() -> MetadataIdConverter.getID(missing, METALAKE))
+        .thenReturn(Optional.empty());
+    try {
+      assertTrue(
+          new AuthorizationExpressionEvaluator("ANY_CREATE_TABLE", 
jcasbinAuthorizer)
+              .evaluate(
+                  tableMetadataNames("catalog", "newTable"), new 
AuthorizationRequestContext()));
+    } finally {
+      metadataIdConverterMockedStatic
+          .when(() -> MetadataIdConverter.getID(missing, METALAKE))
+          .thenReturn(Optional.of(CATALOG_ID));
+    }
+  }
+
+  /** Verifies that a failed DENY guard installation cannot publish a partial 
role. */
+  @Test
+  public void 
testUnresolvedDenyWithoutCatalogIdFailsBeforePoliciesAreApplied() throws 
Exception {
+    MetadataObject catalogObject = MetadataObjects.of(null, "broken", 
MetadataObject.Type.CATALOG);
+    CatalogManager catalogs = gravitinoEnv.catalogManager();
+    Mockito.doThrow(new IllegalStateException("Connector initialization 
failed"))
+        .when(catalogs)
+        .doWithCatalog(eq(NameIdentifier.of(METALAKE, "broken")), any());
+    RoleEntity role =
+        mockRoleInStore(
+            ALLOW_ROLE_ID,
+            "unguardedRole",
+            ImmutableList.of(
+                buildSecurableObject(
+                    ALLOW_ROLE_ID,
+                    MetadataObject.Type.METALAKE,
+                    METALAKE,
+                    Privilege.Name.SELECT_TABLE,
+                    "ALLOW"),
+                buildSecurableObject(
+                    ALLOW_ROLE_ID,
+                    MetadataObject.Type.TABLE,
+                    "broken.schema.table",
+                    Privilege.Name.SELECT_TABLE,
+                    "DENY")));
+    mockDirectUserRoles(role);
+    metadataIdConverterMockedStatic
+        .when(() -> MetadataIdConverter.getID(catalogObject, METALAKE))
+        .thenReturn(Optional.empty());
+    try {
+      AuthorizationRequestContext context = new AuthorizationRequestContext();
+      assertFalse(
+          jcasbinAuthorizer.authorize(
+              PrincipalUtils.getCurrentPrincipal(),
+              METALAKE,
+              MetadataObjects.of(null, METALAKE, MetadataObject.Type.METALAKE),
+              Privilege.Name.SELECT_TABLE,
+              context));
+      assertEquals(Set.of(ALLOW_ROLE_ID), context.getUnreadableRoleIds());
+      assertTrue(
+          jcasbinAuthorizer.hasDenyPolicy(
+              PrincipalUtils.getCurrentPrincipal(),
+              METALAKE,
+              Set.of(Privilege.Name.SELECT_TABLE),
+              context));
+      
assertFalse(getLoadedRolesCache(jcasbinAuthorizer).getIfPresent(ALLOW_ROLE_ID).isPresent());
+      assertTrue(
+          getAllowEnforcer(jcasbinAuthorizer)
+              .getFilteredPolicy(0, String.valueOf(ALLOW_ROLE_ID))
+              .isEmpty());
+      assertTrue(
+          getDenyEnforcer(jcasbinAuthorizer)
+              .getFilteredPolicy(0, String.valueOf(ALLOW_ROLE_ID))
+              .isEmpty());
+      metadataIdConverterMockedStatic
+          .when(() -> MetadataIdConverter.getID(catalogObject, METALAKE))
+          .thenReturn(Optional.of(CATALOG_ID));
+      assertTrue(
+          jcasbinAuthorizer.authorize(
+              PrincipalUtils.getCurrentPrincipal(),
+              METALAKE,
+              MetadataObjects.of(null, METALAKE, MetadataObject.Type.METALAKE),
+              Privilege.Name.SELECT_TABLE,
+              new AuthorizationRequestContext()));
+    } finally {
+      metadataIdConverterMockedStatic
+          .when(() -> MetadataIdConverter.getID(catalogObject, METALAKE))
+          .thenReturn(Optional.of(CATALOG_ID));
+    }
+  }
+
   @Test
   public void testPartialPolicyLoadIsNotRecordedAsLoaded() throws Exception {
     // Regression test for the permanently-denied-role failure mode. When a 
securable object cannot
@@ -2694,6 +3051,15 @@ public class TestJcasbinAuthorizer {
     return group;
   }
 
+  private static Map<Entity.EntityType, NameIdentifier> tableMetadataNames(
+      String catalog, String table) {
+    return ImmutableMap.of(
+        Entity.EntityType.METALAKE, NameIdentifierUtil.ofMetalake(METALAKE),
+        Entity.EntityType.CATALOG, NameIdentifierUtil.ofCatalog(METALAKE, 
catalog),
+        Entity.EntityType.SCHEMA, NameIdentifierUtil.ofSchema(METALAKE, 
catalog, "schema"),
+        Entity.EntityType.TABLE, NameIdentifier.of(METALAKE, catalog, 
"schema", table));
+  }
+
   private Boolean doAuthorize(Principal currentPrincipal) {
     return jcasbinAuthorizer.authorize(
         currentPrincipal,
@@ -2860,10 +3226,16 @@ public class TestJcasbinAuthorizer {
     MetadataObject newObject =
         MetadataObjects.parse("catalog.ScHeMa.RenamedModel", 
MetadataObject.Type.SEMANTIC_MODEL);
     metadataIdConverterMockedStatic
-        .when(() -> MetadataIdConverter.getID(oldObject, METALAKE))
+        .when(
+            () ->
+                MetadataIdConverter.getID(
+                    MetadataIdConverter.normalizeMetadataObject(oldObject, 
METALAKE), METALAKE))
         .thenReturn(Optional.of(100L));
     metadataIdConverterMockedStatic
-        .when(() -> MetadataIdConverter.getID(newObject, METALAKE))
+        .when(
+            () ->
+                MetadataIdConverter.getID(
+                    MetadataIdConverter.normalizeMetadataObject(newObject, 
METALAKE), METALAKE))
         .thenReturn(Optional.of(200L));
     assertEquals(
         Optional.of(100L),
@@ -2882,10 +3254,16 @@ public class TestJcasbinAuthorizer {
     try {
       hook.alterSemanticModel(oldIdent, rename);
       metadataIdConverterMockedStatic
-          .when(() -> MetadataIdConverter.getID(oldObject, METALAKE))
+          .when(
+              () ->
+                  MetadataIdConverter.getID(
+                      MetadataIdConverter.normalizeMetadataObject(oldObject, 
METALAKE), METALAKE))
           .thenReturn(Optional.empty());
       metadataIdConverterMockedStatic
-          .when(() -> MetadataIdConverter.getID(newObject, METALAKE))
+          .when(
+              () ->
+                  MetadataIdConverter.getID(
+                      MetadataIdConverter.normalizeMetadataObject(newObject, 
METALAKE), METALAKE))
           .thenReturn(Optional.of(100L));
       assertEquals(
           Optional.empty(),
@@ -2895,13 +3273,19 @@ public class TestJcasbinAuthorizer {
           lookups.resolveMetadataId(newObject, METALAKE, new 
AuthorizationRequestContext()));
       hook.dropSemanticModel(newIdent);
       metadataIdConverterMockedStatic
-          .when(() -> MetadataIdConverter.getID(newObject, METALAKE))
+          .when(
+              () ->
+                  MetadataIdConverter.getID(
+                      MetadataIdConverter.normalizeMetadataObject(newObject, 
METALAKE), METALAKE))
           .thenReturn(Optional.of(300L));
       assertEquals(
           Optional.of(300L),
           lookups.resolveMetadataId(newObject, METALAKE, new 
AuthorizationRequestContext()));
       metadataIdConverterMockedStatic
-          .when(() -> MetadataIdConverter.getID(oldObject, METALAKE))
+          .when(
+              () ->
+                  MetadataIdConverter.getID(
+                      MetadataIdConverter.normalizeMetadataObject(oldObject, 
METALAKE), METALAKE))
           .thenReturn(Optional.of(400L));
       assertEquals(
           Optional.of(400L),
diff --git 
a/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinMetadataIdCacheNormalization.java
 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinMetadataIdCacheNormalization.java
new file mode 100644
index 0000000000..bd41673861
--- /dev/null
+++ 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinMetadataIdCacheNormalization.java
@@ -0,0 +1,611 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.server.authorization.jcasbin;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Stream;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.gravitino.Catalog;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.EntityStore;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.HasIdentifier;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.MetadataObjects;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.Schema;
+import org.apache.gravitino.authorization.AccessControlDispatcher;
+import org.apache.gravitino.authorization.AuthorizationRequestContext;
+import org.apache.gravitino.authorization.AuthorizationUtils;
+import org.apache.gravitino.authorization.GravitinoAuthorizer;
+import org.apache.gravitino.cache.CaffeineGravitinoCache;
+import org.apache.gravitino.catalog.CatalogDispatcher;
+import org.apache.gravitino.catalog.CatalogManager;
+import org.apache.gravitino.catalog.SchemaDispatcher;
+import org.apache.gravitino.catalog.TableDispatcher;
+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.NoSuchCatalogException;
+import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.hook.SchemaHookDispatcher;
+import org.apache.gravitino.hook.TableHookDispatcher;
+import org.apache.gravitino.rel.Table;
+import org.apache.gravitino.rel.TableChange;
+import org.apache.gravitino.server.authorization.MetadataIdConverter;
+import 
org.apache.gravitino.storage.relational.EntityChangeLogNameIdentifierCodec;
+import org.apache.gravitino.storage.relational.po.auth.OwnerInfo;
+import org.apache.gravitino.storage.relational.po.cache.EntityChangeRecord;
+import org.apache.gravitino.storage.relational.po.cache.OperateType;
+import org.apache.gravitino.utils.EntityClassMapper;
+import org.apache.gravitino.utils.MetadataObjectUtil;
+import org.apache.gravitino.utils.ThrowableFunction;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.MockedStatic;
+
+/** Verifies capability-normalized authorization cache keys through local and 
peer invalidation. */
+public class TestJcasbinMetadataIdCacheNormalization {
+  private static final String METALAKE = "metalake";
+  private static final Capability CASE_INSENSITIVE =
+      new Capability() {
+        @Override
+        public CapabilityResult caseSensitiveOnName(Scope scope) {
+          return CapabilityResult.unsupported("case insensitive");
+        }
+      };
+
+  private final Map<NameIdentifier, Entity> entities = new HashMap<>();
+  private CaffeineGravitinoCache<String, Long> metadataCache;
+  private CaffeineGravitinoCache<Long, Optional<OwnerInfo>> ownerCache;
+  private CatalogManager catalogs;
+  private BaseCatalog<?> catalog;
+  private EntityStore store;
+  private GravitinoEnv env;
+  private JcasbinAuthorizationLookups lookups;
+  private GravitinoAuthorizer authorizer;
+  private MockedStatic<GravitinoEnv> envMock;
+
+  @BeforeEach
+  void setup() throws Exception {
+    metadataCache = new CaffeineGravitinoCache<>(60_000L, 100L);
+    ownerCache = new CaffeineGravitinoCache<>(60_000L, 100L);
+    lookups = new JcasbinAuthorizationLookups(metadataCache, ownerCache);
+    JcasbinAuthorizer localAuthorizer = new JcasbinAuthorizer();
+    // The name-ID hook only needs the same cache owned by the initialized 
authorizer.
+    FieldUtils.writeDeclaredField(localAuthorizer, "metadataIdCache", 
metadataCache, true);
+    authorizer = localAuthorizer;
+    catalog = mock(BaseCatalog.class);
+    when(catalog.capability()).thenReturn(CASE_INSENSITIVE);
+    when(catalog.provider()).thenReturn("test");
+    when(catalog.type()).thenReturn(Catalog.Type.RELATIONAL);
+    catalogs = mock(CatalogManager.class);
+    doAnswer(
+            invocation -> {
+              ThrowableFunction<BaseCatalog<?>, Object> operation = 
invocation.getArgument(1);
+              return operation.apply(catalog);
+            })
+        .when(catalogs)
+        .doWithCatalog(any(), any());
+    store = mock(EntityStore.class);
+    when(store.get(any(), any(), any()))
+        .thenAnswer(
+            invocation -> {
+              NameIdentifier ident = invocation.getArgument(0);
+              Entity entity = entities.get(ident);
+              if (entity == null) {
+                throw new NoSuchEntityException("Missing entity: %s", ident);
+              }
+              return entity;
+            });
+    env = mock(GravitinoEnv.class);
+    when(env.entityStore()).thenReturn(store);
+    when(env.catalogManager()).thenReturn(catalogs);
+    when(env.gravitinoAuthorizer()).thenReturn(authorizer);
+    
when(env.internalAccessControlDispatcher()).thenReturn(mock(AccessControlDispatcher.class));
+    CatalogDispatcher catalogDispatcher = mock(CatalogDispatcher.class);
+    when(catalogDispatcher.loadCatalog(any())).thenReturn(catalog);
+    when(env.internalCatalogDispatcher()).thenReturn(catalogDispatcher);
+    SchemaDispatcher schemaDispatcher = mock(SchemaDispatcher.class);
+    when(schemaDispatcher.loadSchema(any())).thenReturn(mock(Schema.class));
+    when(env.internalSchemaDispatcher()).thenReturn(schemaDispatcher);
+    envMock = mockStatic(GravitinoEnv.class);
+    envMock.when(GravitinoEnv::getInstance).thenReturn(env);
+  }
+
+  @AfterEach
+  void cleanup() {
+    if (envMock != null) {
+      envMock.close();
+    }
+    metadataCache.close();
+    ownerCache.close();
+  }
+
+  @ParameterizedTest
+  @MethodSource("catalogScopedTypes")
+  void 
testAllMappedTypesNormalizeNamesIncludingColumnParents(MetadataObject.Type 
type) {
+    assertEquals(
+        object(type, false),
+        MetadataIdConverter.normalizeMetadataObject(object(type, true), 
METALAKE));
+  }
+
+  @ParameterizedTest
+  @MethodSource("idLookupTypes")
+  void testLocalNotificationEvictsAliasesAfterNameReuse(MetadataObject.Type 
type)
+      throws IOException {
+    MetadataObject alias = object(type, true);
+    MetadataObject normalized = object(type, false);
+    put(normalized, 100L);
+    assertAliasesShareBothCacheTiers(alias, normalized);
+    AuthorizationUtils.notifyEntityNameIdMappingChange(ident(normalized), 
entityType(type));
+    assertEquals(0L, metadataCache.size());
+    entities.remove(ident(normalized));
+    assertEquals(Optional.empty(), resolve(alias));
+    put(normalized, 200L);
+    assertEquals(Optional.of(200L), resolve(alias));
+    assertEquals(Optional.of(200L), resolve(normalized));
+  }
+
+  @ParameterizedTest
+  @MethodSource("idLookupTypes")
+  void testPeerReplayEvictsAliasesAfterRenameAndNameReuse(MetadataObject.Type 
type)
+      throws IOException {
+    MetadataObject alias = object(type, true);
+    MetadataObject normalized = object(type, false);
+    put(normalized, 100L);
+    assertAliasesShareBothCacheTiers(alias, normalized);
+    try (JcasbinChangeListener listener =
+        new JcasbinChangeListener(metadataCache, ownerCache, 3600L)) {
+      listener.onEntityChange(List.of(change(normalized, OperateType.ALTER)));
+    }
+    assertEquals(0L, metadataCache.size());
+    entities.remove(ident(normalized));
+    assertEquals(Optional.empty(), resolve(alias));
+    put(normalized, 300L);
+    assertEquals(Optional.of(300L), resolve(alias));
+    assertEquals(Optional.of(300L), resolve(normalized));
+  }
+
+  @ParameterizedTest
+  @MethodSource("idLookupTypes")
+  void testCaseSensitiveCatalogKeepsNamesDistinct(MetadataObject.Type type) {
+    when(catalog.capability()).thenReturn(Capability.DEFAULT);
+    MetadataObject upper = object(type, true);
+    MetadataObject lower = object(type, false);
+    assertSame(upper, MetadataIdConverter.normalizeMetadataObject(upper, 
METALAKE));
+    put(upper, 100L);
+    put(lower, 200L);
+    assertEquals(Optional.of(100L), resolve(upper));
+    assertEquals(Optional.of(200L), resolve(lower));
+    assertEquals(2L, metadataCache.size());
+    AuthorizationUtils.notifyEntityNameIdMappingChange(ident(upper), 
entityType(type));
+    put(upper, 300L);
+    assertEquals(Optional.of(300L), resolve(upper));
+    assertEquals(Optional.of(200L), resolve(lower));
+  }
+
+  @Test
+  void testTableDropHookEvictsTableAndColumnAliases() throws Exception {
+    MetadataObject table = object(MetadataObject.Type.TABLE, false);
+    MetadataObject column = object(MetadataObject.Type.COLUMN, false);
+    put(table, 100L);
+    // EntityStore does not independently load COLUMN IDs. Seed a descendant 
key to verify the
+    // hook's prefix eviction, without inventing a ColumnEntity that 
implements HasIdentifier.
+    
metadataCache.put(JcasbinAuthorizationCacheKeys.metadataIdCacheKey(METALAKE, 
column), 200L);
+    assertEquals(Optional.of(100L), resolve(object(MetadataObject.Type.TABLE, 
true)));
+    TableDispatcher dispatcher = mock(TableDispatcher.class);
+    when(dispatcher.dropTable(ident(table))).thenReturn(true);
+    assertTrue(new TableHookDispatcher(dispatcher, () -> 
null).dropTable(ident(table)));
+    assertEquals(0L, metadataCache.size());
+    put(table, 300L);
+    assertEquals(Optional.of(300L), resolve(object(MetadataObject.Type.TABLE, 
true)));
+    assertFalse(
+        metadataCache
+            
.getIfPresent(JcasbinAuthorizationCacheKeys.metadataIdCacheKey(METALAKE, 
column))
+            .isPresent());
+  }
+
+  @Test
+  void testTableRenameHookEvictsOldAliasAndAllowsNameReuse() throws Exception {
+    MetadataObject old = object(MetadataObject.Type.TABLE, false);
+    MetadataObject alias = object(MetadataObject.Type.TABLE, true);
+    MetadataObject renamed = MetadataObjects.parse("cat.schema.renamed", 
MetadataObject.Type.TABLE);
+    put(old, 100L);
+    assertEquals(Optional.of(100L), resolve(alias));
+    TableDispatcher dispatcher = mock(TableDispatcher.class);
+    TableChange rename = TableChange.rename("renamed");
+    when(dispatcher.alterTable(ident(old), 
rename)).thenReturn(mock(Table.class));
+    new TableHookDispatcher(dispatcher, () -> null).alterTable(ident(old), 
rename);
+    entities.remove(ident(old));
+    put(renamed, 100L);
+    assertEquals(Optional.empty(), resolve(alias));
+    assertEquals(Optional.of(100L), resolve(renamed));
+    put(old, 200L);
+    assertEquals(Optional.of(200L), resolve(alias));
+  }
+
+  @Test
+  void testSchemaDropHookAndPeerReplayEvictDescendantAliases() throws 
Exception {
+    MetadataObject schema = object(MetadataObject.Type.SCHEMA, false);
+    MetadataObject table = object(MetadataObject.Type.TABLE, false);
+    MetadataObject column = object(MetadataObject.Type.COLUMN, false);
+    put(schema, 100L);
+    put(table, 200L);
+    
metadataCache.put(JcasbinAuthorizationCacheKeys.metadataIdCacheKey(METALAKE, 
column), 300L);
+    resolve(object(MetadataObject.Type.SCHEMA, true));
+    resolve(object(MetadataObject.Type.TABLE, true));
+    SchemaDispatcher dispatcher = mock(SchemaDispatcher.class);
+    when(dispatcher.dropSchema(ident(schema), true)).thenReturn(true);
+    assertTrue(new SchemaHookDispatcher(dispatcher).dropSchema(ident(schema), 
true));
+    assertEquals(0L, metadataCache.size());
+    assertEquals(Optional.of(200L), resolve(object(MetadataObject.Type.TABLE, 
true)));
+    
metadataCache.put(JcasbinAuthorizationCacheKeys.metadataIdCacheKey(METALAKE, 
column), 300L);
+    try (JcasbinChangeListener listener =
+        new JcasbinChangeListener(metadataCache, ownerCache, 3600L)) {
+      listener.onEntityChange(List.of(change(schema, OperateType.DROP)));
+    }
+    assertEquals(0L, metadataCache.size());
+    put(table, 400L);
+    assertEquals(Optional.of(400L), resolve(object(MetadataObject.Type.TABLE, 
true)));
+    assertFalse(
+        metadataCache
+            
.getIfPresent(JcasbinAuthorizationCacheKeys.metadataIdCacheKey(METALAKE, 
column))
+            .isPresent());
+  }
+
+  @Test
+  void testColumnNormalizationHonorsEachScopeAndPreservesCatalogName() {
+    when(catalog.capability())
+        .thenReturn(
+            new Capability() {
+              @Override
+              public CapabilityResult caseSensitiveOnName(Scope scope) {
+                return scope == Scope.TABLE
+                    ? CapabilityResult.SUPPORTED
+                    : CapabilityResult.unsupported("case insensitive");
+              }
+            });
+    MetadataObject column =
+        MetadataObjects.parse("CAT.SCHEMA.Table.CoL", 
MetadataObject.Type.COLUMN);
+    assertEquals(
+        MetadataObjects.parse("CAT.schema.Table.col", 
MetadataObject.Type.COLUMN),
+        MetadataIdConverter.normalizeMetadataObject(column, "Metalake"));
+  }
+
+  @Test
+  void testColumnNormalizationPreservesSchemaAndColumnCaseIndependently() {
+    when(catalog.capability())
+        .thenReturn(
+            new Capability() {
+              @Override
+              public CapabilityResult caseSensitiveOnName(Scope scope) {
+                return scope == Scope.TABLE
+                    ? CapabilityResult.unsupported("case insensitive")
+                    : CapabilityResult.SUPPORTED;
+              }
+            });
+    assertEquals(
+        MetadataObjects.parse("CAT.SCHEMA.table.CoL", 
MetadataObject.Type.COLUMN),
+        MetadataIdConverter.normalizeMetadataObject(
+            MetadataObjects.parse("CAT.SCHEMA.Table.CoL", 
MetadataObject.Type.COLUMN), "Metalake"));
+  }
+
+  @Test
+  void testNormalizationIsReusedWithinRequestAndRefreshedOnNextRequest() 
throws IOException {
+    MetadataObject table = object(MetadataObject.Type.TABLE, false);
+    put(table, 100L);
+    AuthorizationRequestContext context = new AuthorizationRequestContext();
+    assertEquals(Optional.of(100L), lookups.resolveMetadataId(table, METALAKE, 
context));
+    // Normalization resolves capabilities and the physical table name once 
each.
+    // The loader and repeated lookups reuse that result within the same 
request.
+    verify(catalogs, times(2)).doWithCatalog(any(), any());
+    assertEquals(Optional.of(100L), lookups.resolveMetadataId(table, METALAKE, 
context));
+    verify(catalogs, times(2)).doWithCatalog(any(), any());
+    assertEquals(Optional.of(100L), resolve(table));
+    verify(catalogs, times(4)).doWithCatalog(any(), any());
+    verify(catalog, times(2)).capability();
+    verify(catalog, times(2)).ops();
+    verify(store, times(1)).get(any(), any(), any());
+    doThrow(new NoSuchCatalogException("Missing catalog"))
+        .when(catalogs)
+        .doWithCatalog(any(), any());
+    assertEquals(Optional.empty(), resolve(table));
+  }
+
+  @Test
+  void testFailedNormalizationCanRetryInSameRequest() {
+    MetadataObject table = object(MetadataObject.Type.TABLE, false);
+    put(table, 100L);
+    AuthorizationRequestContext context = new AuthorizationRequestContext();
+    doThrow(new NoSuchCatalogException("Missing catalog"))
+        .doAnswer(
+            invocation -> {
+              ThrowableFunction<BaseCatalog<?>, Object> operation = 
invocation.getArgument(1);
+              return operation.apply(catalog);
+            })
+        .when(catalogs)
+        .doWithCatalog(any(), any());
+    assertEquals(Optional.empty(), lookups.resolveMetadataId(table, METALAKE, 
context));
+    assertEquals(Optional.of(100L), lookups.resolveMetadataId(table, METALAKE, 
context));
+  }
+
+  @Test
+  void testLoaderUsesTheCanonicalNameOfItsCacheKey() {
+    MetadataObject alias = object(MetadataObject.Type.TABLE, true);
+    MetadataObject normalized = object(MetadataObject.Type.TABLE, false);
+    put(normalized, 100L);
+    put(alias, 200L);
+    // A later request can observe different rules; the loader still uses its 
own canonical key.
+    when(catalog.capability()).thenReturn(CASE_INSENSITIVE, 
Capability.DEFAULT);
+    assertEquals(Optional.of(100L), resolve(alias));
+    assertEquals(Optional.of(100L), resolve(normalized));
+    assertEquals(1L, metadataCache.size());
+  }
+
+  @Test
+  void testNonCatalogScopesKeepNamesAndSkipCapabilityLookup() {
+    for (MetadataObject.Type type :
+        List.of(
+            MetadataObject.Type.METALAKE, MetadataObject.Type.CATALOG, 
MetadataObject.Type.ROLE)) {
+      MetadataObject object = MetadataObjects.of(null, "MixedName", type);
+      assertSame(object, MetadataIdConverter.normalizeMetadataObject(object, 
METALAKE));
+    }
+    verifyNoInteractions(catalogs);
+  }
+
+  @Test
+  void testMissingCatalogDoesNotReturnCachedId() {
+    MetadataObject table = object(MetadataObject.Type.TABLE, false);
+    put(table, 100L);
+    assertEquals(Optional.of(100L), resolve(table));
+    doThrow(new NoSuchCatalogException("Missing catalog"))
+        .when(catalogs)
+        .doWithCatalog(any(), any());
+    assertEquals(Optional.empty(), resolve(table));
+  }
+
+  @Test
+  void testCapabilityFailureDoesNotReturnSharedOrRequestCachedId() {
+    MetadataObject alias = object(MetadataObject.Type.TABLE, true);
+    MetadataObject normalized = object(MetadataObject.Type.TABLE, false);
+    put(normalized, 100L);
+    AuthorizationRequestContext context = new AuthorizationRequestContext();
+    assertEquals(Optional.of(100L), lookups.resolveMetadataId(normalized, 
METALAKE, context));
+    doThrow(new IllegalStateException("Connector initialization failed"))
+        .when(catalogs)
+        .doWithCatalog(any(), any());
+    // This spelling is new to the request, but its canonical ID is present in 
both cache tiers.
+    assertEquals(Optional.empty(), lookups.resolveMetadataId(alias, METALAKE, 
context));
+    assertEquals(Optional.empty(), resolve(alias));
+    assertEquals(1L, metadataCache.size());
+  }
+
+  @Test
+  void testCapabilityFailureOnCacheMissDoesNotLoadOrCacheId() {
+    MetadataObject table = object(MetadataObject.Type.TABLE, true);
+    doThrow(new IllegalStateException("Connector initialization failed"))
+        .when(catalogs)
+        .doWithCatalog(any(), any());
+    assertEquals(Optional.empty(), resolve(table));
+    assertEquals(0L, metadataCache.size());
+    verifyNoInteractions(store);
+  }
+
+  @Test
+  void testCapabilityFailureRetriesOnlyInNextRequest() throws IOException {
+    MetadataObject table = object(MetadataObject.Type.TABLE, true);
+    put(object(MetadataObject.Type.TABLE, false), 100L);
+    AuthorizationRequestContext context = new AuthorizationRequestContext();
+    doThrow(new IllegalStateException("Connector initialization failed"))
+        .doAnswer(
+            invocation -> {
+              ThrowableFunction<BaseCatalog<?>, Object> operation = 
invocation.getArgument(1);
+              return operation.apply(catalog);
+            })
+        .when(catalogs)
+        .doWithCatalog(any(), any());
+    assertEquals(Optional.empty(), lookups.resolveMetadataId(table, METALAKE, 
context));
+    assertEquals(Optional.empty(), lookups.resolveMetadataId(table, METALAKE, 
context));
+    MetadataObject schema = MetadataObjects.parse("cat.SCHEMA", 
MetadataObject.Type.SCHEMA);
+    assertEquals(Optional.empty(), lookups.resolveMetadataId(schema, METALAKE, 
context));
+    verify(catalogs, times(1)).doWithCatalog(any(), any());
+    verifyNoInteractions(store);
+    assertEquals(
+        Optional.of(100L),
+        lookups.resolveMetadataId(table, METALAKE, new 
AuthorizationRequestContext()));
+    verify(catalogs, times(3)).doWithCatalog(any(), any());
+    verify(store, times(1)).get(any(), any(), any());
+  }
+
+  @Test
+  void testPersistentCapabilityFailureIsIsolatedToItsCatalog() {
+    MetadataObject broken = object(MetadataObject.Type.TABLE, true);
+    MetadataObject healthy =
+        MetadataObjects.parse("healthy.SCHEMA.TABLE", 
MetadataObject.Type.TABLE);
+    MetadataObject catalogObject = MetadataObjects.of(null, "cat", 
MetadataObject.Type.CATALOG);
+    put(MetadataObjects.parse("healthy.schema.table", 
MetadataObject.Type.TABLE), 200L);
+    put(catalogObject, 300L);
+    doThrow(new IllegalStateException("Connector initialization failed"))
+        .when(catalogs)
+        .doWithCatalog(eq(NameIdentifier.of(METALAKE, "cat")), any());
+    AuthorizationRequestContext context = new AuthorizationRequestContext();
+    for (int i = 0; i < 5; i++) {
+      assertTrue(lookups.resolveMetadataIdResult(broken, METALAKE, 
context).normalizationFailed());
+      MetadataObject other =
+          MetadataObjects.parse("cat.SCHEMA.OTHER" + i, 
MetadataObject.Type.TABLE);
+      assertTrue(lookups.resolveMetadataIdResult(other, METALAKE, 
context).normalizationFailed());
+    }
+    assertEquals(Optional.of(200L), lookups.resolveMetadataId(healthy, 
METALAKE, context));
+    assertEquals(Optional.of(300L), lookups.resolveMetadataId(catalogObject, 
METALAKE, context));
+    verify(catalogs, times(1)).doWithCatalog(eq(NameIdentifier.of(METALAKE, 
"cat")), any());
+    verify(catalogs, times(2)).doWithCatalog(eq(NameIdentifier.of(METALAKE, 
"healthy")), any());
+  }
+
+  @Test
+  void testEntityStoreFailureStillPropagatesWithCause() throws IOException {
+    MetadataObject table = object(MetadataObject.Type.TABLE, true);
+    IOException failure = new IOException("Entity store unavailable");
+    doThrow(failure).when(store).get(any(), any(), any());
+    RuntimeException exception = assertThrows(RuntimeException.class, () -> 
resolve(table));
+    assertSame(failure, exception.getCause());
+    assertTrue(exception.getMessage().contains("cat.schema.object"));
+    assertEquals(0L, metadataCache.size());
+  }
+
+  @Test
+  void testViewsAndFunctionsNormalizeNamesLikeTheirDispatchers() {
+    // Listed explicitly: the parameterized cases derive from the mapping and 
would vanish with it.
+    for (MetadataObject.Type type :
+        List.of(MetadataObject.Type.VIEW, MetadataObject.Type.FUNCTION)) {
+      put(object(type, false), 100L);
+      assertEquals(
+          object(type, false),
+          MetadataIdConverter.normalizeMetadataObject(object(type, true), 
METALAKE));
+      assertEquals(Optional.of(100L), resolve(object(type, true)));
+    }
+  }
+
+  @Test
+  void testSemanticModelLeafRemainsCaseSensitiveInInsensitiveCatalog() {
+    MetadataObject upper =
+        MetadataObjects.parse("cat.SCHEMA.Model", 
MetadataObject.Type.SEMANTIC_MODEL);
+    MetadataObject lower =
+        MetadataObjects.parse("cat.schema.model", 
MetadataObject.Type.SEMANTIC_MODEL);
+    put(object(MetadataObject.Type.SEMANTIC_MODEL, false), 100L);
+    put(lower, 200L);
+    assertEquals(Optional.of(100L), resolve(upper));
+    assertEquals(Optional.of(200L), resolve(lower));
+    assertEquals(2L, metadataCache.size());
+  }
+
+  private void assertAliasesShareBothCacheTiers(MetadataObject alias, 
MetadataObject normalized)
+      throws IOException {
+    AuthorizationRequestContext context = new AuthorizationRequestContext();
+    assertEquals(Optional.of(100L), lookups.resolveMetadataId(alias, METALAKE, 
context));
+    assertEquals(Optional.of(100L), lookups.resolveMetadataId(normalized, 
METALAKE, context));
+    assertEquals(Optional.of(100L), resolve(alias));
+    assertEquals(1L, metadataCache.size());
+    assertTrue(
+        metadataCache
+            
.getIfPresent(JcasbinAuthorizationCacheKeys.metadataIdCacheKey(METALAKE, 
normalized))
+            .isPresent());
+    assertFalse(
+        metadataCache
+            
.getIfPresent(JcasbinAuthorizationCacheKeys.metadataIdCacheKey(METALAKE, alias))
+            .isPresent());
+    verify(store, times(1))
+        .get(
+            ident(normalized),
+            entityType(normalized.type()),
+            EntityClassMapper.getEntityClass(entityType(normalized.type())));
+  }
+
+  private Optional<Long> resolve(MetadataObject object) {
+    return lookups.resolveMetadataId(object, METALAKE, new 
AuthorizationRequestContext());
+  }
+
+  private void put(MetadataObject object, long id) {
+    Entity entity = 
mock(EntityClassMapper.getEntityClass(entityType(object.type())));
+    when(((HasIdentifier) entity).id()).thenReturn(id);
+    entities.put(ident(object), entity);
+  }
+
+  private static NameIdentifier ident(MetadataObject object) {
+    return MetadataObjectUtil.toEntityIdent(METALAKE, object);
+  }
+
+  private static Entity.EntityType entityType(MetadataObject.Type type) {
+    return MetadataObjectUtil.toEntityType(type);
+  }
+
+  private static EntityChangeRecord change(MetadataObject object, OperateType 
operation) {
+    return new EntityChangeRecord(
+        1L,
+        METALAKE,
+        object.type().name(),
+        EntityChangeLogNameIdentifierCodec.encode(ident(object)),
+        operation,
+        1L);
+  }
+
+  private static MetadataObject object(MetadataObject.Type type, boolean 
alias) {
+    String name;
+    switch (type) {
+      case SCHEMA:
+        name = alias ? "cat.SCHEMA" : "cat.schema";
+        break;
+      case COLUMN:
+        name = alias ? "cat.SCHEMA.OBJECT.CoL" : "cat.schema.object.col";
+        break;
+      case SEMANTIC_MODEL:
+        name = alias ? "cat.SCHEMA.Model" : "cat.schema.Model";
+        break;
+      case TABLE:
+      case VIEW:
+      case FUNCTION:
+      case MODEL:
+      case FILESET:
+      case TOPIC:
+        name = alias ? "cat.SCHEMA.OBJECT" : "cat.schema.object";
+        break;
+      default:
+        throw new IllegalArgumentException("Add normalization coverage for " + 
type);
+    }
+    return MetadataObjects.parse(name, type);
+  }
+
+  private static Stream<MetadataObject.Type> idLookupTypes() throws 
IllegalAccessException {
+    // Only HasIdentifier entities satisfy EntityStore.get's production 
contract. COLUMN remains
+    // covered by normalization and descendant-key eviction, not by a 
fabricated ID-load fixture.
+    return catalogScopedTypes()
+        .filter(
+            type ->
+                HasIdentifier.class.isAssignableFrom(
+                    EntityClassMapper.getEntityClass(entityType(type))));
+  }
+
+  private static Stream<MetadataObject.Type> catalogScopedTypes() {
+    // Derive coverage from the production registry: a newly mapped type 
cannot silently be missed.
+    return MetadataIdConverter.catalogScopedTypes().stream();
+  }
+}
diff --git 
a/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinModelCacheInvalidation.java
 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinModelCacheInvalidation.java
index 7a6f67b607..f3788cf5a1 100644
--- 
a/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinModelCacheInvalidation.java
+++ 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinModelCacheInvalidation.java
@@ -83,14 +83,17 @@ public class TestJcasbinModelCacheInvalidation {
         MockedStatic<GravitinoEnv> environment = 
mockStatic(GravitinoEnv.class);
         MockedStatic<CapabilityHelpers> capabilities =
             mockStatic(CapabilityHelpers.class, CALLS_REAL_METHODS);
-        MockedStatic<MetadataIdConverter> converter = 
mockStatic(MetadataIdConverter.class)) {
+        MockedStatic<MetadataIdConverter> converter =
+            mockStatic(MetadataIdConverter.class, CALLS_REAL_METHODS)) {
       environment.when(GravitinoEnv::getInstance).thenReturn(env);
       capabilities
           .when(() -> CapabilityHelpers.getCapability(any(), 
eq(catalogManager)))
           .thenReturn(Capability.DEFAULT);
       converter
-          .when(() -> MetadataIdConverter.getID(MODEL, METALAKE))
+          .when(() -> MetadataIdConverter.getIdForNormalizedObject(MODEL, 
METALAKE))
           .thenReturn(Optional.of(10L), Optional.of(30L));
+      // With real methods enabled, the stubbing call above is recorded as an 
invocation.
+      converter.clearInvocations();
       // Use the real JCasbin hook and shared caches without starting its 
background poller.
       JcasbinAuthorizer authorizer = mock(JcasbinAuthorizer.class, 
CALLS_REAL_METHODS);
       FieldUtils.writeField(authorizer, "metadataIdCache", metadataIdCache, 
true);
@@ -121,7 +124,8 @@ public class TestJcasbinModelCacheInvalidation {
       Assertions.assertEquals(
           Optional.of(30L),
           lookups.resolveMetadataId(MODEL, METALAKE, new 
AuthorizationRequestContext()));
-      converter.verify(() -> MetadataIdConverter.getID(MODEL, METALAKE), 
times(2));
+      converter.verify(
+          () -> MetadataIdConverter.getIdForNormalizedObject(MODEL, METALAKE), 
times(2));
     }
   }
 

Reply via email to