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

roryqi 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 5aea36da5d [#12977] fix(server): Report dotted metadata names clearly 
(#12980)
5aea36da5d is described below

commit 5aea36da5d7a9460a9b2e35e94e938c1161de4d9
Author: roryqi <[email protected]>
AuthorDate: Tue Sep 8 18:28:27 2026 +0800

    [#12977] fix(server): Report dotted metadata names clearly (#12980)
    
    ### What changes were proposed in this pull request?
    
    - Validate dotted metadata object names before authorization converts
    structured identifiers into qualified names.
    - Return HTTP 400 for unsupported dotted metadata names instead of
    wrapping the validation failure as an internal authorization error.
    - Report dotted list results explicitly when authorization requires
    per-object metadata conversion.
    - Preserve connector-compatible dotted names when authorization is
    disabled.
    - Add regression coverage for dotted table and topic names, list
    filtering, and authorization interception.
    
    ### Why are the changes needed?
    
    External systems such as Kafka and PostgreSQL may contain objects whose
    names include dots. Gravitino reserves `.` as the qualified-name
    separator, so authorization metadata conversion cannot represent these
    names.
    
    The existing behavior is misleading and inconsistent:
    
    - Loading an affected object with authorization enabled may return HTTP
    500 with an authorization failure message.
    - Per-object list authorization may silently remove the object, making
    the catalog appear complete.
    
    The proposed validation returns a clear client error at the
    authorization boundary without changing existing connector behavior when
    authorization is disabled.
    
    Fix: #12977
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes.
    
    When authorization metadata conversion encounters an unsupported dotted
    object name, the request now returns HTTP 400 with an explicit message
    such as:
    
    ```text
    The TOPIC name 'orders.created.v1' is unsupported because '.' is reserved 
as the qualified-name separator.
    ```
    
    List operations with authorization enabled report the unsupported name
    instead of silently omitting it. With authorization disabled,
    connector-supported dotted names remain available.
    
    ### How was this patch tested?
    
    ```bash
    ./gradlew \
      :core:test --tests org.apache.gravitino.utils.TestNameIdentifierUtil \
      :server-common:test --tests 
org.apache.gravitino.server.authorization.TestMetadataAuthzHelper \
      :server:test --tests 
org.apache.gravitino.server.web.filter.TestGravitinoInterceptionService \
      -PskipITs -PskipWeb=true
    
    ./gradlew :catalogs:catalog-jdbc-mysql:test \
      --tests 
org.apache.gravitino.catalog.mysql.integration.test.CatalogMysqlIT.testListSchemaWithDotInMysqlUnderlyingDatabaseName
 \
      --tests 
org.apache.gravitino.catalog.mysql.integration.test.CatalogMysqlIT.testObjectNamesWithDots
 \
      -PskipDockerTests=false
    ```
    
    All tests and Spotless checks passed.
---
 .../apache/gravitino/utils/NameIdentifierUtil.java | 20 ++++++++++
 .../gravitino/utils/TestNameIdentifierUtil.java    | 24 ++++++++++++
 .../server/authorization/MetadataAuthzHelper.java  | 12 ++++++
 .../authorization/TestMetadataAuthzHelper.java     | 44 +++++++++++++++++++++
 .../web/filter/GravitinoInterceptionService.java   |  4 ++
 .../filter/TestGravitinoInterceptionService.java   | 45 ++++++++++++++++++++++
 6 files changed, 149 insertions(+)

diff --git 
a/core/src/main/java/org/apache/gravitino/utils/NameIdentifierUtil.java 
b/core/src/main/java/org/apache/gravitino/utils/NameIdentifierUtil.java
index 19b664adad..8efb9aab7b 100644
--- a/core/src/main/java/org/apache/gravitino/utils/NameIdentifierUtil.java
+++ b/core/src/main/java/org/apache/gravitino/utils/NameIdentifierUtil.java
@@ -618,6 +618,25 @@ public class NameIdentifierUtil {
     NamespaceUtil.checkJobTemplate(ident.namespace());
   }
 
+  /**
+   * Check whether the metadata object name can be represented in a qualified 
metadata object name.
+   *
+   * @param ident The metadata object identifier to check
+   * @param entityType The metadata object entity type
+   * @throws IllegalNameIdentifierException If the object name contains the 
qualified-name separator
+   */
+  public static void checkMetadataObjectName(NameIdentifier ident, 
Entity.EntityType entityType) {
+    Preconditions.checkArgument(
+        ident != null && entityType != null, "The identifier and entity type 
must not be null");
+
+    if (ident.name().contains(".")) {
+      throw new IllegalNameIdentifierException(
+          "The %s name '%s' is unsupported because '.' is reserved as the 
qualified-name "
+              + "separator.",
+          entityType, ident.name());
+    }
+  }
+
   /**
    * Convert the given {@link NameIdentifier} and {@link Entity.EntityType} to 
{@link
    * MetadataObject}.
@@ -630,6 +649,7 @@ public class NameIdentifierUtil {
       NameIdentifier ident, Entity.EntityType entityType) {
     Preconditions.checkArgument(
         ident != null && entityType != null, "The identifier and entity type 
must not be null");
+    checkMetadataObjectName(ident, entityType);
 
     Joiner dot = Joiner.on(".");
 
diff --git 
a/core/src/test/java/org/apache/gravitino/utils/TestNameIdentifierUtil.java 
b/core/src/test/java/org/apache/gravitino/utils/TestNameIdentifierUtil.java
index 0567651247..01ec887408 100644
--- a/core/src/test/java/org/apache/gravitino/utils/TestNameIdentifierUtil.java
+++ b/core/src/test/java/org/apache/gravitino/utils/TestNameIdentifierUtil.java
@@ -173,6 +173,30 @@ public class TestNameIdentifierUtil {
     assertTrue(e3.getMessage().contains("Entity type MODEL_VERSION is not 
supported"));
   }
 
+  @Test
+  public void testRejectDottedMetadataObjectName() {
+    NameIdentifier table = NameIdentifier.of("metalake1", "catalog1", 
"schema1", "sales.2024");
+    IllegalNameIdentifierException tableException =
+        assertThrows(
+            IllegalNameIdentifierException.class,
+            () -> NameIdentifierUtil.toMetadataObject(table, 
Entity.EntityType.TABLE));
+    assertEquals(
+        "The TABLE name 'sales.2024' is unsupported because '.' is reserved as 
the "
+            + "qualified-name separator.",
+        tableException.getMessage());
+
+    NameIdentifier topic =
+        NameIdentifier.of("metalake1", "catalog1", "schema1", 
"orders.created.v1");
+    IllegalNameIdentifierException topicException =
+        assertThrows(
+            IllegalNameIdentifierException.class,
+            () -> NameIdentifierUtil.toMetadataObject(topic, 
Entity.EntityType.TOPIC));
+    assertEquals(
+        "The TOPIC name 'orders.created.v1' is unsupported because '.' is 
reserved as the "
+            + "qualified-name separator.",
+        topicException.getMessage());
+  }
+
   @Test
   void testOfUser() {
     String userName = "userA";
diff --git 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/MetadataAuthzHelper.java
 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/MetadataAuthzHelper.java
index c3ac8dbef3..02506e97d4 100644
--- 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/MetadataAuthzHelper.java
+++ 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/MetadataAuthzHelper.java
@@ -31,6 +31,7 @@ import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.Executor;
 import java.util.concurrent.Executors;
 import java.util.function.Function;
+import java.util.stream.Collectors;
 import org.apache.gravitino.Config;
 import org.apache.gravitino.Configs;
 import org.apache.gravitino.Entity;
@@ -89,6 +90,11 @@ public class MetadataAuthzHelper {
   private static final List<Entity.EntityType> REQUIRE_SCHEMA_EXISTS =
       Arrays.asList(Entity.EntityType.TABLE, Entity.EntityType.TOPIC);
 
+  private static final Set<Entity.EntityType> METADATA_OBJECT_ENTITY_TYPES =
+      Arrays.stream(MetadataObject.Type.values())
+          .map(type -> Entity.EntityType.valueOf(type.name()))
+          .collect(Collectors.toUnmodifiableSet());
+
   private static final String TABLE_PARENT_SCOPES = "METALAKE, CATALOG, 
SCHEMA";
   private static final String SCHEMA_PARENT_SCOPES = "METALAKE, CATALOG";
   private static final String CATALOG_PARENT_SCOPES = "METALAKE";
@@ -376,6 +382,12 @@ public class MetadataAuthzHelper {
     NameIdentifier[] nameIdentifiers =
         
Arrays.stream(entities).map(toNameIdentifier).toArray(NameIdentifier[]::new);
     if (enableAuthorization() && nameIdentifiers.length > 0) {
+      if (METADATA_OBJECT_ENTITY_TYPES.contains(entityType)) {
+        Arrays.stream(nameIdentifiers)
+            .forEach(
+                identifier -> 
NameIdentifierUtil.checkMetadataObjectName(identifier, entityType));
+      }
+
       String principalName = PrincipalUtils.getCurrentPrincipal().getName();
       if (allVisibleViaParentScope(metalake, expression, entityType, 
nameIdentifiers)) {
         // A privilege granted at a parent scope (metalake/catalog/schema) 
makes every object in
diff --git 
a/server-common/src/test/java/org/apache/gravitino/server/authorization/TestMetadataAuthzHelper.java
 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/TestMetadataAuthzHelper.java
index cec329b30c..3fe7fc6e89 100644
--- 
a/server-common/src/test/java/org/apache/gravitino/server/authorization/TestMetadataAuthzHelper.java
+++ 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/TestMetadataAuthzHelper.java
@@ -44,6 +44,7 @@ import org.apache.gravitino.authorization.GravitinoAuthorizer;
 import org.apache.gravitino.authorization.Privilege;
 import org.apache.gravitino.catalog.SchemaDispatcher;
 import org.apache.gravitino.dto.tag.MetadataObjectDTO;
+import org.apache.gravitino.exceptions.IllegalNameIdentifierException;
 import 
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants;
 import org.apache.gravitino.utils.NameIdentifierUtil;
 import org.apache.gravitino.utils.PrincipalUtils;
@@ -115,6 +116,49 @@ public class TestMetadataAuthzHelper {
     }
   }
 
+  @ParameterizedTest
+  @EnumSource(
+      value = Entity.EntityType.class,
+      names = {"TABLE", "TOPIC"})
+  public void testFilterRejectsDottedExternalObjectName(Entity.EntityType 
entityType) {
+    NameIdentifier[] identifiers = {
+      NameIdentifier.of("testMetalake", "testCatalog", "testSchema", 
"object.with.dot")
+    };
+
+    IllegalNameIdentifierException exception =
+        Assertions.assertThrows(
+            IllegalNameIdentifierException.class,
+            () ->
+                MetadataAuthzHelper.filterByExpression(
+                    "testMetalake", "", entityType, identifiers));
+
+    Assertions.assertEquals(
+        "The "
+            + entityType
+            + " name 'object.with.dot' is unsupported because '.' is reserved 
as the "
+            + "qualified-name separator.",
+        exception.getMessage());
+  }
+
+  @Test
+  public void 
testFilterPreservesDottedExternalObjectNameWithoutAuthorization() {
+    Config config = gravitinoEnv.config();
+    when(config.get(eq(Configs.ENABLE_AUTHORIZATION))).thenReturn(false);
+    NameIdentifier[] identifiers = {
+      NameIdentifier.of("testMetalake", "testCatalog", "testSchema", 
"object.with.dot")
+    };
+
+    try {
+      NameIdentifier[] filtered =
+          MetadataAuthzHelper.filterByExpression(
+              "testMetalake", "", Entity.EntityType.TABLE, identifiers);
+
+      Assertions.assertSame(identifiers, filtered);
+    } finally {
+      when(config.get(eq(Configs.ENABLE_AUTHORIZATION))).thenReturn(true);
+    }
+  }
+
   @Test
   public void testPreloadUsesInternalDispatchers() throws Exception {
     AccessControlDispatcher accessControlDispatcher = 
mock(AccessControlDispatcher.class);
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
 
b/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
index 462c8ed6ff..4220a79f20 100644
--- 
a/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
@@ -46,6 +46,7 @@ import 
org.apache.gravitino.authorization.AuthorizationRequestContext;
 import org.apache.gravitino.authorization.AuthorizationUtils;
 import org.apache.gravitino.exceptions.BadRequestException;
 import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.gravitino.exceptions.IllegalNameIdentifierException;
 import org.apache.gravitino.exceptions.NoSuchMetalakeException;
 import org.apache.gravitino.lineage.source.rest.LineageOperations;
 import 
org.apache.gravitino.listener.api.event.server.AuthorizationDenialFailureEvent;
@@ -262,6 +263,9 @@ public class GravitinoInterceptionService implements 
InterceptionService {
           }
         }
         return methodInvocation.proceed();
+      } catch (IllegalNameIdentifierException ex) {
+        LOG.warn("Invalid metadata object identifier during authorization", 
ex);
+        return Utils.illegalArguments(ex.getMessage(), ex);
       } catch (Exception ex) {
         String currentUser = PrincipalUtils.getCurrentUserName();
         String methodName = methodInvocation.getMethod().getName();
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
 
b/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
index 2d9e83857c..0e06ba92dd 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
@@ -358,6 +358,51 @@ public class TestGravitinoInterceptionService {
     }
   }
 
+  @Test
+  public void testDottedMetadataNameReturnsBadRequest() throws Throwable {
+    try (MockedStatic<PrincipalUtils> principalUtilsMocked = 
mockStatic(PrincipalUtils.class);
+        MockedStatic<GravitinoAuthorizerProvider> authorizerMocked =
+            mockStatic(GravitinoAuthorizerProvider.class);
+        MockedStatic<AuthorizationUtils> authorizationUtilsMocked =
+            mockStatic(AuthorizationUtils.class)) {
+      principalUtilsMocked
+          .when(PrincipalUtils::getCurrentPrincipal)
+          .thenReturn(new UserPrincipal("tester"));
+      
principalUtilsMocked.when(PrincipalUtils::getCurrentUserName).thenReturn("tester");
+      authorizationUtilsMocked
+          .when(
+              () ->
+                  AuthorizationUtils.checkCurrentUser(
+                      ArgumentMatchers.any(), ArgumentMatchers.any(), 
ArgumentMatchers.any()))
+          .thenAnswer(invocation -> null);
+
+      GravitinoAuthorizerProvider provider = 
mock(GravitinoAuthorizerProvider.class);
+      GravitinoAuthorizer authorizer = tableProbeAuthorizer();
+      
authorizerMocked.when(GravitinoAuthorizerProvider::getInstance).thenReturn(provider);
+      when(provider.getGravitinoAuthorizer()).thenReturn(authorizer);
+
+      Method method =
+          TestTableLoadOperations.class.getMethod(
+              "loadTable", String.class, String.class, String.class, 
String.class, String.class);
+      MethodInvocation invocation = mock(MethodInvocation.class);
+      when(invocation.getMethod()).thenReturn(method);
+      when(invocation.getArguments())
+          .thenReturn(
+              new Object[] {"testMetalake", "testCatalog", "testSchema", 
"sales.2024", null});
+
+      MethodInterceptor interceptor =
+          new 
GravitinoInterceptionService().getMethodInterceptors(method).get(0);
+      Response response = (Response) interceptor.invoke(invocation);
+
+      assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), 
response.getStatus());
+      assertEquals(
+          "The TABLE name 'sales.2024' is unsupported because '.' is reserved 
as the "
+              + "qualified-name separator.",
+          ((ErrorResponse) response.getEntity()).getMessage());
+      verify(invocation, never()).proceed();
+    }
+  }
+
   @Test
   public void testMetalakeNotExist() throws Throwable {
     try (MockedStatic<PrincipalUtils> principalUtilsMocked = 
mockStatic(PrincipalUtils.class);

Reply via email to