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));
}
}