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

mchades 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 37f9d23c85 [#12871] fix(authz): Invalidate function grants after drop 
(#12873)
37f9d23c85 is described below

commit 37f9d23c85dafa2f71c0766a4deb59957bf6645f
Author: Qi Yu <[email protected]>
AuthorDate: Thu Sep 3 22:12:42 2026 +0800

    [#12871] fix(authz): Invalidate function grants after drop (#12873)
    
    ### What changes were proposed in this pull request?
    
    - Invoke the authorization removal hook after successfully dropping a
    function.
    - Invalidate the local JCasbin name-to-ID mapping when metadata
    privileges are removed.
    - Write a Function DROP record to `entity_change_log` in the same
    transaction as the metadata deletion, allowing peer nodes to invalidate
    stale authorization mappings.
    - Add tests for successful/no-op function drops, local invalidation,
    transactional change-log emission and rollback, and peer-side Function
    cache-key invalidation.
    
    ### Why are the changes needed?
    
    Dropping and recreating a function with the same name may reuse a stale
    JCasbin name-to-ID mapping. The old in-memory `EXECUTE_FUNCTION` policy
    can consequently authorize access to the newly created function.
    
    Functions bypass the Entity Store cache, so their drops currently do not
    emit an `entity_change_log` record. The normal polling interval
    therefore does not bound the stale window on peer nodes.
    
    Fix: #12871
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. A function recreated with the same name no longer inherits
    authorization grants associated with the previously dropped function.
    
    ### How was this patch tested?
    
    - `./gradlew spotlessApply`
    - Targeted tests for `TestAuthorizationUtils`,
    `TestFunctionHookDispatcher`, `TestEntityChangeLogService`, and
    `TestJcasbinChangePoller`
    - `./gradlew :core:test :server-common:test -PskipITs -PskipDockerTests`
    - `./gradlew :core:spotlessCheck :server-common:spotlessCheck`
---
 .../java/org/apache/gravitino/GravitinoEnv.java    |   3 +-
 .../authorization/AuthorizationUtils.java          |  13 +-
 .../gravitino/catalog/CapabilityHelpers.java       |  14 ++
 .../catalog/FunctionNormalizeDispatcher.java       |   3 +-
 .../gravitino/hook/FunctionHookDispatcher.java     |  38 +++-
 .../gravitino/storage/relational/JDBCBackend.java  |   8 +-
 .../authorization/TestAuthorizationUtils.java      |  25 +++
 .../gravitino/hook/TestFunctionHookDispatcher.java | 222 +++++++++++----------
 .../service/TestEntityChangeLogService.java        |  44 +++-
 .../jcasbin/TestJcasbinChangePoller.java           |  11 +-
 10 files changed, 253 insertions(+), 128 deletions(-)

diff --git a/core/src/main/java/org/apache/gravitino/GravitinoEnv.java 
b/core/src/main/java/org/apache/gravitino/GravitinoEnv.java
index fea34df90a..142b9e55e2 100644
--- a/core/src/main/java/org/apache/gravitino/GravitinoEnv.java
+++ b/core/src/main/java/org/apache/gravitino/GravitinoEnv.java
@@ -873,7 +873,8 @@ public class GravitinoEnv {
         new FunctionNormalizeDispatcher(functionOperationDispatcher, 
catalogManager);
     FunctionEventDispatcher functionEventDispatcher =
         new FunctionEventDispatcher(eventBus, functionNormalizeDispatcher);
-    this.functionDispatcher = new 
FunctionHookDispatcher(functionEventDispatcher);
+    this.functionDispatcher =
+        new FunctionHookDispatcher(functionEventDispatcher, 
this::ownerDispatcher, catalogManager);
 
     // View operation chain: ViewHookDispatcher -> ViewEventDispatcher -> 
ViewNormalizeDispatcher
     // -> ViewOperationDispatcher.
diff --git 
a/core/src/main/java/org/apache/gravitino/authorization/AuthorizationUtils.java 
b/core/src/main/java/org/apache/gravitino/authorization/AuthorizationUtils.java
index 5224a2d787..362a2835d3 100644
--- 
a/core/src/main/java/org/apache/gravitino/authorization/AuthorizationUtils.java
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/AuthorizationUtils.java
@@ -331,6 +331,7 @@ public class AuthorizationUtils {
     // If we enable authorization, we should remove the privileges about the 
entity in the
     // authorization plugin.
     if (GravitinoEnv.getInstance().accessControlDispatcher() != null) {
+      notifyEntityNameIdMappingChange(ident, type);
       MetadataObject metadataObject = 
NameIdentifierUtil.toMetadataObject(ident, type);
       String metalake =
           type == Entity.EntityType.METALAKE ? ident.name() : 
ident.namespace().level(0);
@@ -390,8 +391,16 @@ public class AuthorizationUtils {
     }
   }
 
-  private static void notifyEntityNameIdMappingChange(
-      NameIdentifier ident, Entity.EntityType type) {
+  /**
+   * Notifies the built-in authorizer that an entity name may now resolve to a 
different ID.
+   *
+   * <p>This does not push a metadata change to the catalog authorization 
plugin. Use it when only
+   * Gravitino's local authorization caches support the entity type.
+   *
+   * @param ident the entity identifier whose mapping changed
+   * @param type the entity type
+   */
+  public static void notifyEntityNameIdMappingChange(NameIdentifier ident, 
Entity.EntityType type) {
     GravitinoAuthorizer gravitinoAuthorizer = 
GravitinoEnv.getInstance().gravitinoAuthorizer();
     if (gravitinoAuthorizer == null) {
       return;
diff --git 
a/core/src/main/java/org/apache/gravitino/catalog/CapabilityHelpers.java 
b/core/src/main/java/org/apache/gravitino/catalog/CapabilityHelpers.java
index 39ad527199..7e5f77c777 100644
--- a/core/src/main/java/org/apache/gravitino/catalog/CapabilityHelpers.java
+++ b/core/src/main/java/org/apache/gravitino/catalog/CapabilityHelpers.java
@@ -148,6 +148,20 @@ public class CapabilityHelpers {
     return NameIdentifier.of(namespace, name);
   }
 
+  /**
+   * Loads the catalog capability for {@code ident} and applies its 
case-sensitivity rules.
+   *
+   * @param ident the identifier to normalize
+   * @param scope the identifier's capability scope
+   * @param catalogManager the catalog manager used to load the capability
+   * @return the case-normalized identifier
+   */
+  public static NameIdentifier applyCaseSensitive(
+      NameIdentifier ident, Capability.Scope scope, CatalogManager 
catalogManager) {
+    Capability capability = getCapability(ident, catalogManager);
+    return applyCaseSensitive(ident, scope, capability);
+  }
+
   public static Namespace applyCaseSensitive(
       Namespace namespace, Capability.Scope identScope, Capability 
capabilities) {
     String metalake = namespace.level(0);
diff --git 
a/core/src/main/java/org/apache/gravitino/catalog/FunctionNormalizeDispatcher.java
 
b/core/src/main/java/org/apache/gravitino/catalog/FunctionNormalizeDispatcher.java
index 03bdf6a0cc..13f83d42fe 100644
--- 
a/core/src/main/java/org/apache/gravitino/catalog/FunctionNormalizeDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/catalog/FunctionNormalizeDispatcher.java
@@ -100,8 +100,7 @@ public class FunctionNormalizeDispatcher implements 
FunctionDispatcher {
   }
 
   private NameIdentifier normalizeCaseSensitive(NameIdentifier functionIdent) {
-    Capability capabilities = getCapability(functionIdent, catalogManager);
-    return applyCaseSensitive(functionIdent, Capability.Scope.FUNCTION, 
capabilities);
+    return applyCaseSensitive(functionIdent, Capability.Scope.FUNCTION, 
catalogManager);
   }
 
   private NameIdentifier normalizeNameIdentifier(NameIdentifier functionIdent) 
{
diff --git 
a/core/src/main/java/org/apache/gravitino/hook/FunctionHookDispatcher.java 
b/core/src/main/java/org/apache/gravitino/hook/FunctionHookDispatcher.java
index dc1758102b..33c70bda57 100644
--- a/core/src/main/java/org/apache/gravitino/hook/FunctionHookDispatcher.java
+++ b/core/src/main/java/org/apache/gravitino/hook/FunctionHookDispatcher.java
@@ -19,13 +19,15 @@
 
 package org.apache.gravitino.hook;
 
+import java.util.function.Supplier;
 import org.apache.gravitino.Entity;
-import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
+import org.apache.gravitino.authorization.AuthorizationUtils;
 import org.apache.gravitino.authorization.Owner;
 import org.apache.gravitino.authorization.OwnerDispatcher;
 import org.apache.gravitino.catalog.CapabilityHelpers;
+import org.apache.gravitino.catalog.CatalogManager;
 import org.apache.gravitino.catalog.FunctionDispatcher;
 import org.apache.gravitino.connector.capability.Capability;
 import org.apache.gravitino.exceptions.FunctionAlreadyExistsException;
@@ -45,9 +47,24 @@ import org.apache.gravitino.utils.PrincipalUtils;
  */
 public class FunctionHookDispatcher implements FunctionDispatcher {
   private final FunctionDispatcher dispatcher;
+  private final Supplier<OwnerDispatcher> ownerDispatcher;
+  private final CatalogManager catalogManager;
 
-  public FunctionHookDispatcher(FunctionDispatcher dispatcher) {
+  /**
+   * Creates a function hook dispatcher.
+   *
+   * @param dispatcher the underlying function dispatcher
+   * @param ownerDispatcher supplies the owner dispatcher, or {@code null} 
when authorization is
+   *     disabled
+   * @param catalogManager the catalog manager used to apply catalog 
capabilities
+   */
+  public FunctionHookDispatcher(
+      FunctionDispatcher dispatcher,
+      Supplier<OwnerDispatcher> ownerDispatcher,
+      CatalogManager catalogManager) {
     this.dispatcher = dispatcher;
+    this.ownerDispatcher = ownerDispatcher;
+    this.catalogManager = catalogManager;
   }
 
   @Override
@@ -82,15 +99,14 @@ public class FunctionHookDispatcher implements 
FunctionDispatcher {
         dispatcher.registerFunction(ident, comment, functionType, 
deterministic, definitions);
 
     // Set the creator as the owner of the function.
-    OwnerDispatcher ownerManager = 
GravitinoEnv.getInstance().ownerDispatcher();
+    OwnerDispatcher ownerManager = ownerDispatcher.get();
     if (ownerManager != null) {
       // The inner NormalizeDispatcher case-folds the function name (and its 
schema namespace)
       // based on catalog capabilities, so the entity is stored under the 
normalized identifier.
       // Apply the same normalization here so the owner is attached to the 
same identifier the
       // manager sees.
       NameIdentifier normalizedIdent =
-          CapabilityHelpers.applyCapabilities(
-              ident, Capability.Scope.FUNCTION, 
GravitinoEnv.getInstance().catalogManager());
+          CapabilityHelpers.applyCapabilities(ident, 
Capability.Scope.FUNCTION, catalogManager);
       ownerManager.setOwner(
           normalizedIdent.namespace().level(0),
           NameIdentifierUtil.toMetadataObject(normalizedIdent, 
Entity.EntityType.FUNCTION),
@@ -108,6 +124,16 @@ public class FunctionHookDispatcher implements 
FunctionDispatcher {
 
   @Override
   public boolean dropFunction(NameIdentifier ident) {
-    return dispatcher.dropFunction(ident);
+    boolean dropped = dispatcher.dropFunction(ident);
+    if (dropped) {
+      NameIdentifier normalizedIdent =
+          CapabilityHelpers.applyCaseSensitive(ident, 
Capability.Scope.FUNCTION, catalogManager);
+      // Function privileges are managed by Gravitino. Catalog authorization 
plugins such as
+      // Ranger HadoopSQL do not support FUNCTION metadata objects, so only 
invalidate the built-in
+      // authorizer's name-to-ID mapping here.
+      AuthorizationUtils.notifyEntityNameIdMappingChange(
+          normalizedIdent, Entity.EntityType.FUNCTION);
+    }
+    return dropped;
   }
 }
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/JDBCBackend.java 
b/core/src/main/java/org/apache/gravitino/storage/relational/JDBCBackend.java
index e3165cd16d..c6f949cfd8 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/JDBCBackend.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/JDBCBackend.java
@@ -367,7 +367,7 @@ public class JDBCBackend implements RelationalBackend, 
SupportsOrphanedRelationC
   @Override
   public boolean delete(NameIdentifier ident, Entity.EntityType entityType, 
boolean cascade)
       throws IOException {
-    if (!BaseEntityCache.isCacheable(entityType)) {
+    if (!shouldRecordEntityDrop(entityType)) {
       return deleteEntity(ident, entityType, cascade);
     }
 
@@ -1121,6 +1121,12 @@ public class JDBCBackend implements RelationalBackend, 
SupportsOrphanedRelationC
                 operateType));
   }
 
+  private static boolean shouldRecordEntityDrop(Entity.EntityType entityType) {
+    // Functions bypass the Entity Store cache, but their drops must still 
invalidate JCasbin's
+    // name-to-ID cache on peer nodes.
+    return BaseEntityCache.isCacheable(entityType) || entityType == 
Entity.EntityType.FUNCTION;
+  }
+
   /** Start JDBC database if necessary. For example, start the H2 database if 
the backend is H2. */
   private static JDBCDatabase startJDBCDatabaseIfNecessary(Config config) {
     String jdbcUrl = config.get(Configs.ENTITY_RELATIONAL_JDBC_BACKEND_URL);
diff --git 
a/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationUtils.java
 
b/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationUtils.java
index e568d6f825..5bd5798970 100644
--- 
a/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationUtils.java
+++ 
b/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationUtils.java
@@ -352,6 +352,31 @@ class TestAuthorizationUtils {
     }
   }
 
+  @Test
+  void testRemovePrivilegesNotifiesEntityNameIdMappingChange() {
+    GravitinoAuthorizer authorizer = Mockito.mock(GravitinoAuthorizer.class);
+    AccessControlDispatcher accessControlDispatcher = 
Mockito.mock(AccessControlDispatcher.class);
+    CatalogManager catalogManager = Mockito.mock(CatalogManager.class);
+    BaseCatalog<?> baseCatalog = Mockito.mock(BaseCatalog.class);
+    
Mockito.when(catalogManager.loadCatalog(Mockito.any())).thenReturn(baseCatalog);
+
+    GravitinoEnv envMock = Mockito.mock(GravitinoEnv.class);
+    Mockito.when(envMock.gravitinoAuthorizer()).thenReturn(authorizer);
+    
Mockito.when(envMock.accessControlDispatcher()).thenReturn(accessControlDispatcher);
+    Mockito.when(envMock.catalogManager()).thenReturn(catalogManager);
+
+    try (MockedStatic<GravitinoEnv> envStatic = 
Mockito.mockStatic(GravitinoEnv.class)) {
+      envStatic.when(GravitinoEnv::getInstance).thenReturn(envMock);
+
+      NameIdentifier ident = NameIdentifier.of("metalake", "catalog", 
"schema", "table");
+      AuthorizationUtils.authorizationPluginRemovePrivileges(
+          ident, Entity.EntityType.TABLE, Collections.emptyList());
+
+      Mockito.verify(authorizer)
+          .handleEntityNameIdMappingChange("metalake", ident, 
Entity.EntityType.TABLE);
+    }
+  }
+
   @Test
   void 
testRenameTablePrivilegesNotifiesAuthorizationPluginWithExpectedChange() {
     NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "schema", 
"table");
diff --git 
a/core/src/test/java/org/apache/gravitino/hook/TestFunctionHookDispatcher.java 
b/core/src/test/java/org/apache/gravitino/hook/TestFunctionHookDispatcher.java
index faba932cd6..104bcb6847 100644
--- 
a/core/src/test/java/org/apache/gravitino/hook/TestFunctionHookDispatcher.java
+++ 
b/core/src/test/java/org/apache/gravitino/hook/TestFunctionHookDispatcher.java
@@ -19,20 +19,26 @@
 package org.apache.gravitino.hook;
 
 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 org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.gravitino.Entity;
 import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.auth.AuthConstants;
+import org.apache.gravitino.authorization.AccessControlDispatcher;
+import org.apache.gravitino.authorization.GravitinoAuthorizer;
 import org.apache.gravitino.authorization.Owner;
 import org.apache.gravitino.authorization.OwnerDispatcher;
 import org.apache.gravitino.catalog.CatalogManager;
 import org.apache.gravitino.catalog.FunctionDispatcher;
+import org.apache.gravitino.connector.BaseCatalog;
+import org.apache.gravitino.connector.authorization.AuthorizationPlugin;
 import org.apache.gravitino.connector.capability.Capability;
 import org.apache.gravitino.connector.capability.CapabilityResult;
 import org.apache.gravitino.function.Function;
@@ -40,16 +46,13 @@ import org.apache.gravitino.function.FunctionDefinition;
 import org.apache.gravitino.function.FunctionType;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
 import org.mockito.Mockito;
 
 public class TestFunctionHookDispatcher {
 
   @Test
   public void testRegisterFunctionSetOwnerAfterRegister() throws Exception {
-    GravitinoEnv gravitinoEnv = GravitinoEnv.getInstance();
-    Object originalOwnerDispatcher = FieldUtils.readField(gravitinoEnv, 
"ownerDispatcher", true);
-    Object originalCatalogManager = FieldUtils.readField(gravitinoEnv, 
"catalogManager", true);
-
     NameIdentifier functionIdentifier =
         NameIdentifier.of("metalake1", "catalog1", "schema1", "func1");
     FunctionDefinition[] definitions = new FunctionDefinition[] {};
@@ -57,13 +60,7 @@ public class TestFunctionHookDispatcher {
     Function registeredFunction = Mockito.mock(Function.class);
     OwnerDispatcher ownerDispatcher = Mockito.mock(OwnerDispatcher.class);
 
-    // Wire a case-sensitive capability so the un-normalized identifier 
reaches setOwner unchanged,
-    // while still exercising the normalization codepath in the hook.
-    CatalogManager catalogManager = Mockito.mock(CatalogManager.class);
-    CatalogManager.CatalogWrapper catalogWrapper =
-        Mockito.mock(CatalogManager.CatalogWrapper.class);
-    Mockito.when(catalogWrapper.capabilities()).thenReturn(Capability.DEFAULT);
-    
Mockito.when(catalogManager.loadCatalogAndWrap(any())).thenReturn(catalogWrapper);
+    CatalogManager catalogManager = catalogManagerWith(Capability.DEFAULT);
 
     Mockito.when(
             dispatcher.registerFunction(
@@ -74,37 +71,28 @@ public class TestFunctionHookDispatcher {
                 Mockito.eq(definitions)))
         .thenReturn(registeredFunction);
 
-    FieldUtils.writeField(gravitinoEnv, "ownerDispatcher", ownerDispatcher, 
true);
-    FieldUtils.writeField(gravitinoEnv, "catalogManager", catalogManager, 
true);
-    try {
-      FunctionHookDispatcher hookDispatcher = new 
FunctionHookDispatcher(dispatcher);
-      Function result =
-          hookDispatcher.registerFunction(
-              functionIdentifier, "comment", FunctionType.SCALAR, true, 
definitions);
-
-      assertSame(registeredFunction, result);
-
-      ArgumentCaptor<MetadataObject> metadataObjectCaptor =
-          ArgumentCaptor.forClass(MetadataObject.class);
-      Mockito.verify(ownerDispatcher)
-          .setOwner(
-              Mockito.eq("metalake1"),
-              metadataObjectCaptor.capture(),
-              Mockito.eq(AuthConstants.ANONYMOUS_USER),
-              Mockito.eq(Owner.Type.USER));
-      assertEquals(MetadataObject.Type.FUNCTION, 
metadataObjectCaptor.getValue().type());
-      assertEquals("catalog1.schema1.func1", 
metadataObjectCaptor.getValue().fullName());
-    } finally {
-      FieldUtils.writeField(gravitinoEnv, "ownerDispatcher", 
originalOwnerDispatcher, true);
-      FieldUtils.writeField(gravitinoEnv, "catalogManager", 
originalCatalogManager, true);
-    }
+    FunctionHookDispatcher hookDispatcher =
+        new FunctionHookDispatcher(dispatcher, () -> ownerDispatcher, 
catalogManager);
+    Function result =
+        hookDispatcher.registerFunction(
+            functionIdentifier, "comment", FunctionType.SCALAR, true, 
definitions);
+
+    assertSame(registeredFunction, result);
+
+    ArgumentCaptor<MetadataObject> metadataObjectCaptor =
+        ArgumentCaptor.forClass(MetadataObject.class);
+    Mockito.verify(ownerDispatcher)
+        .setOwner(
+            Mockito.eq("metalake1"),
+            metadataObjectCaptor.capture(),
+            Mockito.eq(AuthConstants.ANONYMOUS_USER),
+            Mockito.eq(Owner.Type.USER));
+    assertEquals(MetadataObject.Type.FUNCTION, 
metadataObjectCaptor.getValue().type());
+    assertEquals("catalog1.schema1.func1", 
metadataObjectCaptor.getValue().fullName());
   }
 
   @Test
-  public void testRegisterFunctionSucceedsWhenOwnerDispatcherIsDisabled() 
throws Exception {
-    GravitinoEnv gravitinoEnv = GravitinoEnv.getInstance();
-    Object originalOwnerDispatcher = FieldUtils.readField(gravitinoEnv, 
"ownerDispatcher", true);
-
+  public void testRegisterFunctionSucceedsWhenOwnerDispatcherIsDisabled() {
     NameIdentifier functionIdentifier =
         NameIdentifier.of("metalake1", "catalog1", "schema1", "func1");
     FunctionDefinition[] definitions = new FunctionDefinition[] {};
@@ -120,33 +108,24 @@ public class TestFunctionHookDispatcher {
                 Mockito.eq(definitions)))
         .thenReturn(registeredFunction);
 
-    FieldUtils.writeField(gravitinoEnv, "ownerDispatcher", null, true);
-    try {
-      FunctionHookDispatcher hookDispatcher = new 
FunctionHookDispatcher(dispatcher);
-      Function result =
-          hookDispatcher.registerFunction(
-              functionIdentifier, "comment", FunctionType.SCALAR, true, 
definitions);
-
-      assertSame(registeredFunction, result);
-      Mockito.verify(dispatcher)
-          .registerFunction(functionIdentifier, "comment", 
FunctionType.SCALAR, true, definitions);
-    } finally {
-      FieldUtils.writeField(gravitinoEnv, "ownerDispatcher", 
originalOwnerDispatcher, true);
-    }
+    CatalogManager catalogManager = Mockito.mock(CatalogManager.class);
+    FunctionHookDispatcher hookDispatcher =
+        new FunctionHookDispatcher(dispatcher, () -> null, catalogManager);
+    Function result =
+        hookDispatcher.registerFunction(
+            functionIdentifier, "comment", FunctionType.SCALAR, true, 
definitions);
+
+    assertSame(registeredFunction, result);
+    Mockito.verify(dispatcher)
+        .registerFunction(functionIdentifier, "comment", FunctionType.SCALAR, 
true, definitions);
+    Mockito.verifyNoInteractions(catalogManager);
   }
 
   @Test
   public void testRegisterFunctionSetsOwnerWithNormalizedIdentifier() throws 
Exception {
     // Verifies the hook applies Capability.Scope.FUNCTION normalization 
before setOwner, so the
     // owner relation references the same identifier that NormalizeDispatcher 
persists under.
-    GravitinoEnv gravitinoEnv = GravitinoEnv.getInstance();
-    Object originalOwnerDispatcher = FieldUtils.readField(gravitinoEnv, 
"ownerDispatcher", true);
-    Object originalCatalogManager = FieldUtils.readField(gravitinoEnv, 
"catalogManager", true);
-
-    CatalogManager mockCatalogManager = Mockito.mock(CatalogManager.class);
-    CatalogManager.CatalogWrapper mockWrapper = 
Mockito.mock(CatalogManager.CatalogWrapper.class);
-    Mockito.when(mockWrapper.capabilities()).thenReturn(new 
CaseInsensitiveCapability());
-    
Mockito.when(mockCatalogManager.loadCatalogAndWrap(any())).thenReturn(mockWrapper);
+    CatalogManager catalogManager = catalogManagerWith(new 
CaseInsensitiveCapability());
 
     OwnerDispatcher mockOwnerDispatcher = Mockito.mock(OwnerDispatcher.class);
     FunctionDispatcher mockFunctionDispatcher = 
Mockito.mock(FunctionDispatcher.class);
@@ -157,49 +136,27 @@ public class TestFunctionHookDispatcher {
                 any(), any(), any(), Mockito.anyBoolean(), any()))
         .thenReturn(mockFunction);
 
-    FieldUtils.writeField(gravitinoEnv, "catalogManager", mockCatalogManager, 
true);
-    FieldUtils.writeField(gravitinoEnv, "ownerDispatcher", 
mockOwnerDispatcher, true);
-
-    try {
-      FunctionHookDispatcher hook = new 
FunctionHookDispatcher(mockFunctionDispatcher);
-      NameIdentifier ident = NameIdentifier.of("metalake1", "catalog1", 
"SCHEMA_NORM", "MY_FUNC");
-      hook.registerFunction(ident, "comment", FunctionType.SCALAR, true, 
definitions);
-
-      ArgumentCaptor<MetadataObject> captor = 
ArgumentCaptor.forClass(MetadataObject.class);
-      Mockito.verify(mockOwnerDispatcher)
-          .setOwner(eq("metalake1"), captor.capture(), any(), 
eq(Owner.Type.USER));
-      assertEquals(
-          "my_func",
-          captor.getValue().name(),
-          "Function name passed to setOwner must be lowercased by 
Capability.Scope.FUNCTION"
-              + " normalization");
-      assertEquals(
-          "catalog1.schema_norm",
-          captor.getValue().parent(),
-          "Function parent (catalog.schema) must have its schema component 
lowercased by"
-              + " Capability.Scope.FUNCTION namespace normalization");
-    } finally {
-      FieldUtils.writeField(gravitinoEnv, "ownerDispatcher", 
originalOwnerDispatcher, true);
-      FieldUtils.writeField(gravitinoEnv, "catalogManager", 
originalCatalogManager, true);
-    }
+    FunctionHookDispatcher hook =
+        new FunctionHookDispatcher(
+            mockFunctionDispatcher, () -> mockOwnerDispatcher, catalogManager);
+    NameIdentifier ident = NameIdentifier.of("metalake1", "catalog1", 
"SCHEMA_NORM", "MY_FUNC");
+    hook.registerFunction(ident, "comment", FunctionType.SCALAR, true, 
definitions);
+
+    ArgumentCaptor<MetadataObject> captor = 
ArgumentCaptor.forClass(MetadataObject.class);
+    Mockito.verify(mockOwnerDispatcher)
+        .setOwner(eq("metalake1"), captor.capture(), any(), 
eq(Owner.Type.USER));
+    assertEquals("my_func", captor.getValue().name());
+    assertEquals("catalog1.schema_norm", captor.getValue().parent());
   }
 
   @Test
   public void testRegisterFunctionThrowsWhenSetOwnerFails() throws Exception {
-    GravitinoEnv gravitinoEnv = GravitinoEnv.getInstance();
-    Object originalOwnerDispatcher = FieldUtils.readField(gravitinoEnv, 
"ownerDispatcher", true);
-    Object originalCatalogManager = FieldUtils.readField(gravitinoEnv, 
"catalogManager", true);
-
     OwnerDispatcher mockOwnerDispatcher = Mockito.mock(OwnerDispatcher.class);
     Mockito.doThrow(new RuntimeException("Set owner failed"))
         .when(mockOwnerDispatcher)
         .setOwner(any(), any(), any(), any());
 
-    CatalogManager catalogManager = Mockito.mock(CatalogManager.class);
-    CatalogManager.CatalogWrapper catalogWrapper =
-        Mockito.mock(CatalogManager.CatalogWrapper.class);
-    Mockito.when(catalogWrapper.capabilities()).thenReturn(Capability.DEFAULT);
-    
Mockito.when(catalogManager.loadCatalogAndWrap(any())).thenReturn(catalogWrapper);
+    CatalogManager catalogManager = catalogManagerWith(Capability.DEFAULT);
 
     FunctionDispatcher mockFunctionDispatcher = 
Mockito.mock(FunctionDispatcher.class);
     Function mockFunction = Mockito.mock(Function.class);
@@ -209,25 +166,74 @@ public class TestFunctionHookDispatcher {
                 any(), any(), any(), Mockito.anyBoolean(), any()))
         .thenReturn(mockFunction);
 
-    FieldUtils.writeField(gravitinoEnv, "ownerDispatcher", 
mockOwnerDispatcher, true);
-    FieldUtils.writeField(gravitinoEnv, "catalogManager", catalogManager, 
true);
+    FunctionHookDispatcher hook =
+        new FunctionHookDispatcher(
+            mockFunctionDispatcher, () -> mockOwnerDispatcher, catalogManager);
+    NameIdentifier ident =
+        NameIdentifier.of("metalake1", "catalog1", "schema_owner_fail", 
"func_owner_fail");
+    RuntimeException thrown =
+        assertThrows(
+            RuntimeException.class,
+            () -> hook.registerFunction(ident, "comment", FunctionType.SCALAR, 
true, definitions));
+    assertEquals("Set owner failed", thrown.getMessage());
+  }
 
-    try {
-      FunctionHookDispatcher hook = new 
FunctionHookDispatcher(mockFunctionDispatcher);
-      NameIdentifier ident =
-          NameIdentifier.of("metalake1", "catalog1", "schema_owner_fail", 
"func_owner_fail");
+  @Test
+  public void 
testDropFunctionInvalidatesOnlyInternalCacheWithNormalizedIdentifier()
+      throws Exception {
+    NameIdentifier functionIdentifier =
+        NameIdentifier.of("metalake1", "catalog1", "SCHEMA1", "FUNC1");
+    NameIdentifier normalizedIdentifier =
+        NameIdentifier.of("metalake1", "catalog1", "schema1", "func1");
+    FunctionDispatcher dispatcher = Mockito.mock(FunctionDispatcher.class);
+    Mockito.when(dispatcher.dropFunction(functionIdentifier))
+        .thenReturn(true, false)
+        .thenThrow(new RuntimeException("Drop failed"));
+    CatalogManager catalogManager = catalogManagerWith(new 
CaseInsensitiveCapability());
+
+    GravitinoAuthorizer authorizer = Mockito.mock(GravitinoAuthorizer.class);
+    AuthorizationPlugin catalogAuthorizationPlugin = 
Mockito.mock(AuthorizationPlugin.class);
+    BaseCatalog<?> catalog = Mockito.mock(BaseCatalog.class);
+    
Mockito.when(catalog.getAuthorizationPlugin()).thenReturn(catalogAuthorizationPlugin);
+    Mockito.when(catalogManager.loadCatalog(any())).thenReturn(catalog);
+
+    GravitinoEnv env = Mockito.mock(GravitinoEnv.class);
+    Mockito.when(env.gravitinoAuthorizer()).thenReturn(authorizer);
+    Mockito.when(env.accessControlDispatcher())
+        .thenReturn(Mockito.mock(AccessControlDispatcher.class));
+    Mockito.when(env.catalogManager()).thenReturn(catalogManager);
+
+    try (MockedStatic<GravitinoEnv> envStatic = 
Mockito.mockStatic(GravitinoEnv.class)) {
+      envStatic.when(GravitinoEnv::getInstance).thenReturn(env);
+      FunctionHookDispatcher hookDispatcher =
+          new FunctionHookDispatcher(dispatcher, () -> null, catalogManager);
+
+      assertTrue(hookDispatcher.dropFunction(functionIdentifier));
+      assertFalse(hookDispatcher.dropFunction(functionIdentifier));
       RuntimeException thrown =
           assertThrows(
-              RuntimeException.class,
-              () ->
-                  hook.registerFunction(ident, "comment", FunctionType.SCALAR, 
true, definitions));
-      assertEquals("Set owner failed", thrown.getMessage());
-    } finally {
-      FieldUtils.writeField(gravitinoEnv, "ownerDispatcher", 
originalOwnerDispatcher, true);
-      FieldUtils.writeField(gravitinoEnv, "catalogManager", 
originalCatalogManager, true);
+              RuntimeException.class, () -> 
hookDispatcher.dropFunction(functionIdentifier));
+      assertEquals("Drop failed", thrown.getMessage());
+      Mockito.verify(authorizer)
+          .handleEntityNameIdMappingChange(
+              "metalake1", normalizedIdentifier, Entity.EntityType.FUNCTION);
+      // Ranger HadoopSQL is one catalog plugin that rejects FUNCTION 
metadata. The generic plugin
+      // mock represents that integration boundary and must not receive a 
removal callback.
+      Mockito.verifyNoInteractions(catalogAuthorizationPlugin);
+      Mockito.verify(catalogManager, Mockito.never()).loadCatalog(any());
+      Mockito.verify(catalogManager, 
Mockito.times(1)).loadCatalogAndWrap(any());
     }
   }
 
+  private static CatalogManager catalogManagerWith(Capability capability) 
throws Exception {
+    CatalogManager catalogManager = Mockito.mock(CatalogManager.class);
+    CatalogManager.CatalogWrapper catalogWrapper =
+        Mockito.mock(CatalogManager.CatalogWrapper.class);
+    Mockito.when(catalogWrapper.capabilities()).thenReturn(capability);
+    
Mockito.when(catalogManager.loadCatalogAndWrap(any())).thenReturn(catalogWrapper);
+    return catalogManager;
+  }
+
   private static class CaseInsensitiveCapability implements Capability {
     @Override
     public CapabilityResult caseSensitiveOnName(Scope scope) {
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestEntityChangeLogService.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestEntityChangeLogService.java
index 1f30e64612..54bb8b76e6 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestEntityChangeLogService.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestEntityChangeLogService.java
@@ -34,6 +34,7 @@ import org.apache.gravitino.job.JobHandle;
 import org.apache.gravitino.meta.BaseMetalake;
 import org.apache.gravitino.meta.CatalogEntity;
 import org.apache.gravitino.meta.FilesetEntity;
+import org.apache.gravitino.meta.FunctionEntity;
 import org.apache.gravitino.meta.JobEntity;
 import org.apache.gravitino.meta.JobTemplateEntity;
 import org.apache.gravitino.meta.ModelEntity;
@@ -241,14 +242,21 @@ public class TestEntityChangeLogService extends 
TestJDBCBackend {
   }
 
   @TestTemplate
-  void testEntityUpdateRollsBackWhenChangeLogInsertFails() throws Exception {
+  void testEntityMutationsRollBackWhenChangeLogInsertFails() throws Exception {
     createAndInsertMakeLake(METALAKE_NAME);
     CatalogEntity catalog = createAndInsertCatalog(METALAKE_NAME, 
CATALOG_NAME);
+    createAndInsertSchema(METALAKE_NAME, CATALOG_NAME, SCHEMA_NAME);
+    FunctionEntity function =
+        createFunctionEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            NamespaceUtil.ofFunction(METALAKE_NAME, CATALOG_NAME, SCHEMA_NAME),
+            "function_rolled_back",
+            AUDIT_INFO);
+    FunctionMetaService.getInstance().insertFunction(function, false);
     long maxIdBeforeUpdate = maxEntityChangeId();
 
-    // Take the change-log table away so that the entity row is updated first 
and the change-log
-    // insert then fails inside the same transaction. This is the only 
ordering the rollback in
-    // JDBCBackend#update protects against, and the caller owns no outer 
transaction here.
+    // Take the change-log table away so that each metadata mutation succeeds 
first and its
+    // change-log insert then fails inside the same transaction.
     renameTable("entity_change_log", "entity_change_log_bak");
     try {
       Assertions.assertThrows(
@@ -263,14 +271,22 @@ public class TestEntityChangeLogService extends 
TestJDBCBackend {
                           catalog.namespace(),
                           CATALOG_NAME + "_rolled_back",
                           AUDIT_INFO)));
+      Assertions.assertThrows(
+          Exception.class,
+          () ->
+              backend.delete(
+                  function.nameIdentifier(), Entity.EntityType.FUNCTION, false 
/* cascade */));
     } finally {
       renameTable("entity_change_log_bak", "entity_change_log");
     }
 
-    // The entity mutation must not survive a failed change-log write.
+    // Neither mutation may survive a failed change-log write.
     CatalogEntity persistedCatalog =
         backend.get(catalog.nameIdentifier(), Entity.EntityType.CATALOG);
     Assertions.assertEquals(CATALOG_NAME, persistedCatalog.name());
+    FunctionEntity persistedFunction =
+        
FunctionMetaService.getInstance().getFunctionByIdentifier(function.nameIdentifier());
+    Assertions.assertEquals(function.id(), persistedFunction.id());
     Assertions.assertEquals(maxIdBeforeUpdate, maxEntityChangeId());
   }
 
@@ -463,6 +479,24 @@ public class TestEntityChangeLogService extends 
TestJDBCBackend {
         Entity.EntityType.MODEL,
         NameIdentifierUtil.ofModel(METALAKE_NAME, CATALOG_NAME, SCHEMA_NAME, 
"model2").toString(),
         OperateType.DROP);
+
+    FunctionEntity function =
+        createFunctionEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            NamespaceUtil.ofFunction(METALAKE_NAME, CATALOG_NAME, SCHEMA_NAME),
+            "function1",
+            AUDIT_INFO);
+    FunctionMetaService.getInstance().insertFunction(function, false);
+    long maxIdBeforeFunctionDrop = maxEntityChangeId();
+    Assertions.assertTrue(
+        backend.delete(function.nameIdentifier(), Entity.EntityType.FUNCTION, 
false /* cascade */));
+    assertEntityChange(
+        maxIdBeforeFunctionDrop,
+        METALAKE_NAME,
+        Entity.EntityType.FUNCTION,
+        NameIdentifierUtil.ofFunction(METALAKE_NAME, CATALOG_NAME, 
SCHEMA_NAME, "function1")
+            .toString(),
+        OperateType.DROP);
   }
 
   @TestTemplate
diff --git 
a/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinChangePoller.java
 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinChangePoller.java
index 8204892c4f..3d4f6c1017 100644
--- 
a/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinChangePoller.java
+++ 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinChangePoller.java
@@ -172,11 +172,16 @@ public class TestJcasbinChangePoller {
     RecordingCache<Long, Optional<OwnerInfo>> ownerRelCache = new 
RecordingCache<>();
 
     JcasbinChangeListener poller = new JcasbinChangeListener(metadataIdCache, 
ownerRelCache, 1);
-    poller.onEntityChange(List.of(change(1L, MetadataObject.Type.FILESET, 
"ml1.cat1.sch1.fs1")));
+    poller.onEntityChange(
+        List.of(
+            change(1L, MetadataObject.Type.FILESET, "ml1.cat1.sch1.fs1"),
+            change(2L, MetadataObject.Type.FUNCTION, "ml1.cat1.sch1.func1")));
 
-    // A FILESET has nothing nested under it, so it is removed by its exact 
key, not by prefix.
+    // Leaf objects have nothing nested under them, so they are removed by 
exact key, not prefix.
     Assertions.assertEquals(
-        List.of(key("ml1", "CATALOG", "cat1", "SCHEMA", "sch1", "FILESET", 
"fs1")),
+        List.of(
+            key("ml1", "CATALOG", "cat1", "SCHEMA", "sch1", "FILESET", "fs1"),
+            key("ml1", "CATALOG", "cat1", "SCHEMA", "sch1", "FUNCTION", 
"func1")),
         metadataIdCache.invalidatedKeys);
     Assertions.assertEquals(List.of(), metadataIdCache.invalidatedPrefixes);
     Assertions.assertEquals(0, metadataIdCache.invalidateAllCalls);

Reply via email to