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 942e5d6ba2 [#14668] fix(server): Return 400 for blank catalog names 
(#14669)
942e5d6ba2 is described below

commit 942e5d6ba221dde177f931fb5380cfc6dd3ba2a3
Author: mchades <[email protected]>
AuthorDate: Fri Nov 6 21:13:01 2026 +0800

    [#14668] fix(server): Return 400 for blank catalog names (#14669)
    
    ### What changes were proposed in this pull request?
    
    Convert metadata-name parsing failures to
    `IllegalMetadataObjectException` before evaluating authorization
    expressions, preserving existing identifier-validation exceptions. Keep
    unexpected authorizer configuration, executor, and resource failures on
    the interceptor's existing HTTP 500 path. Resource-level handlers retain
    the more specific parsing-error type, including Semantic Model
    operations.
    
    ### Why are the changes needed?
    
    Whitespace-only catalog names fail validation before the REST resource
    runs and currently return a generic HTTP 500 authorization error.
    
    Fixes #14668
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. Whitespace-only catalog names return HTTP 400 with code `1001` and
    the validation message. Resource and bulk error responses can now report
    `IllegalMetadataObjectException` instead of `IllegalArgumentException`
    for metadata-name parsing failures; this type refinement is accepted for
    2.0. Unexpected failures in the authorization interceptor retain generic
    HTTP 500 responses and health-failure recording.
    
    ### How was this patch tested?
    
    - All 790 server tests, 11 evaluator tests, and 5 HTTP integration cases
    passed (806 total). This includes all 24 Semantic Model
    whitespace-source cases and the interceptor's internal-error response
    and health-recording regressions.
    - Formatting checks and `git diff --check` passed. Repository-wide and
    Docker tests were not run locally.
    
    ```sh
    ./gradlew :server:spotlessApply :server:spotlessCheck 
:server-common:spotlessCheck :clients:client-java:spotlessCheck :server:test 
:server-common:test --tests '*TestAuthorizationExpressionEvaluator' 
:clients:client-java:test --tests '*InvalidCatalogNameAuthorizationIT' --tests 
'*InvalidMetadataObjectTypeAuthorizationIT' -PskipWeb=true 
-PskipDockerTests=true
    ```
---
 .../InvalidCatalogNameAuthorizationIT.java         |  72 +++++++++++++
 .../AuthorizationExpressionEvaluator.java          |  13 ++-
 .../TestAuthorizationExpressionEvaluator.java      |  54 ++++++++++
 .../filter/TestGravitinoInterceptionService.java   | 120 ++++++++++++++++++++-
 .../web/rest/TestSemanticModelOperations.java      |   3 +-
 5 files changed, 256 insertions(+), 6 deletions(-)

diff --git 
a/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/InvalidCatalogNameAuthorizationIT.java
 
b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/InvalidCatalogNameAuthorizationIT.java
new file mode 100644
index 0000000000..df88a727bd
--- /dev/null
+++ 
b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/InvalidCatalogNameAuthorizationIT.java
@@ -0,0 +1,72 @@
+/*
+ * 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.client.integration.test.authorization;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import org.apache.gravitino.auth.AuthConstants;
+import org.apache.gravitino.dto.responses.ErrorConstants;
+import org.apache.gravitino.dto.responses.ErrorResponse;
+import org.apache.gravitino.server.web.ObjectMapperProvider;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/** Integration tests for invalid Catalog names with authorization enabled. */
+public class InvalidCatalogNameAuthorizationIT extends 
BaseRestApiAuthorizationIT {
+
+  /**
+   * Verifies that URL-encoded whitespace in a Catalog name returns a JSON 
bad-request response.
+   *
+   * @param encodedName the percent-encoded invalid Catalog name
+   */
+  @ParameterizedTest
+  @ValueSource(strings = {"%20", "%09", "%0A", "%20%09%0A"})
+  public void testBlankCatalogNameReturnsBadRequest(String encodedName) throws 
Exception {
+    String authorization =
+        AuthConstants.AUTHORIZATION_BASIC_HEADER
+            + Base64.getEncoder()
+                .encodeToString((USER + 
":dummy").getBytes(StandardCharsets.UTF_8));
+    HttpRequest request =
+        HttpRequest.newBuilder()
+            .uri(new URI(serverUri + "/api/metalakes/" + METALAKE + 
"/catalogs/" + encodedName))
+            .header(AuthConstants.HTTP_HEADER_AUTHORIZATION, authorization)
+            .header("Accept", "application/vnd.gravitino.v1+json")
+            .GET()
+            .build();
+
+    HttpResponse<String> response =
+        HttpClient.newHttpClient().send(request, 
HttpResponse.BodyHandlers.ofString());
+
+    Assertions.assertEquals(400, response.statusCode(), "Unexpected body: " + 
response.body());
+    Assertions.assertTrue(
+        
response.headers().firstValue("Content-Type").orElse("").startsWith("application/json"));
+    ErrorResponse errorResponse =
+        ObjectMapperProvider.objectMapper().readValue(response.body(), 
ErrorResponse.class);
+    Assertions.assertEquals(ErrorConstants.ILLEGAL_ARGUMENTS_CODE, 
errorResponse.getCode());
+    Assertions.assertEquals(
+        IllegalArgumentException.class.getSimpleName(), 
errorResponse.getType());
+    Assertions.assertEquals(
+        "Metadata object full name cannot be blank", 
errorResponse.getMessage());
+  }
+}
diff --git 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionEvaluator.java
 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionEvaluator.java
index 0311de01f0..5fcc56bd3e 100644
--- 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionEvaluator.java
+++ 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionEvaluator.java
@@ -33,6 +33,8 @@ import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.authorization.AuthorizationRequestContext;
 import org.apache.gravitino.authorization.GravitinoAuthorizer;
+import org.apache.gravitino.exceptions.IllegalMetadataObjectException;
+import org.apache.gravitino.exceptions.IllegalNameIdentifierException;
 import org.apache.gravitino.server.authorization.GravitinoAuthorizerProvider;
 import org.apache.gravitino.utils.NameIdentifierUtil;
 import org.apache.gravitino.utils.PrincipalUtils;
@@ -183,8 +185,15 @@ public class AuthorizationExpressionEvaluator {
     metadataNames.forEach(
         (type, entityNameIdent) -> {
           if (isMetadataType(type)) {
-            MetadataObject metadataObject =
-                NameIdentifierUtil.toMetadataObject(entityNameIdent, type);
+            MetadataObject metadataObject;
+            try {
+              metadataObject = 
NameIdentifierUtil.toMetadataObject(entityNameIdent, type);
+            } catch (IllegalNameIdentifierException e) {
+              throw e;
+            } catch (IllegalArgumentException e) {
+              // Only metadata-name parsing failures are client errors, not 
authorizer failures.
+              throw new IllegalMetadataObjectException(e, "%s", 
e.getMessage());
+            }
             ognlContext.put(type.name(), metadataObject);
           }
           ognlContext.put(type.name() + "_NAME_IDENT", entityNameIdent);
diff --git 
a/server-common/src/test/java/org/apache/gravitino/server/authorization/expression/TestAuthorizationExpressionEvaluator.java
 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/expression/TestAuthorizationExpressionEvaluator.java
index 88cd841cf7..df34e7850f 100644
--- 
a/server-common/src/test/java/org/apache/gravitino/server/authorization/expression/TestAuthorizationExpressionEvaluator.java
+++ 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/expression/TestAuthorizationExpressionEvaluator.java
@@ -20,6 +20,7 @@ package org.apache.gravitino.server.authorization.expression;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 
 import java.util.HashMap;
@@ -41,6 +42,8 @@ import org.apache.gravitino.UserPrincipal;
 import org.apache.gravitino.authorization.AuthorizationRequestContext;
 import org.apache.gravitino.authorization.GravitinoAuthorizer;
 import org.apache.gravitino.authorization.Privilege;
+import org.apache.gravitino.exceptions.IllegalMetadataObjectException;
+import org.apache.gravitino.exceptions.IllegalNameIdentifierException;
 import org.apache.gravitino.server.authorization.GravitinoAuthorizerProvider;
 import org.apache.gravitino.server.authorization.MockGravitinoAuthorizer;
 import org.apache.gravitino.utils.NameIdentifierUtil;
@@ -48,6 +51,8 @@ import org.apache.gravitino.utils.PrincipalUtils;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 import org.mockito.MockedStatic;
 
 /** Test for {@link AuthorizationExpressionEvaluator} */
@@ -97,6 +102,55 @@ public class TestAuthorizationExpressionEvaluator {
     }
   }
 
+  /**
+   * Verifies that blank names are classified as invalid metadata before the 
authorizer is called.
+   *
+   * @param catalogName the whitespace-only catalog name
+   */
+  @ParameterizedTest
+  @ValueSource(strings = {" ", "\t", "\n", " \t\n"})
+  public void testBlankCatalogNameIsInvalidMetadata(String catalogName) {
+    GravitinoAuthorizer authorizer = mock(GravitinoAuthorizer.class);
+    AuthorizationExpressionEvaluator evaluator =
+        new AuthorizationExpressionEvaluator("CATALOG::USE_CATALOG", 
authorizer);
+    Map<Entity.EntityType, NameIdentifier> names =
+        Map.of(Entity.EntityType.CATALOG, 
NameIdentifierUtil.ofCatalog("metalake", catalogName));
+
+    IllegalMetadataObjectException error =
+        Assertions.assertThrows(
+            IllegalMetadataObjectException.class,
+            () ->
+                evaluator.evaluate(
+                    names,
+                    new AuthorizationRequestContext(),
+                    new UserPrincipal("tester"),
+                    Optional.empty()));
+
+    Assertions.assertEquals("Metadata object full name cannot be blank", 
error.getMessage());
+    Assertions.assertInstanceOf(IllegalArgumentException.class, 
error.getCause());
+    verifyNoInteractions(authorizer);
+  }
+
+  /** Verifies that existing identifier-validation errors retain their 
exception type. */
+  @Test
+  public void testDottedCatalogNameRetainsIdentifierError() {
+    GravitinoAuthorizer authorizer = mock(GravitinoAuthorizer.class);
+    AuthorizationExpressionEvaluator evaluator =
+        new AuthorizationExpressionEvaluator("CATALOG::USE_CATALOG", 
authorizer);
+    Map<Entity.EntityType, NameIdentifier> names =
+        Map.of(Entity.EntityType.CATALOG, 
NameIdentifierUtil.ofCatalog("metalake", "sales.2024"));
+
+    Assertions.assertThrows(
+        IllegalNameIdentifierException.class,
+        () ->
+            evaluator.evaluate(
+                names,
+                new AuthorizationRequestContext(),
+                new UserPrincipal("tester"),
+                Optional.empty()));
+    verifyNoInteractions(authorizer);
+  }
+
   @Test
   public void testEvaluatorWithOwner() {
     String expression = "METALAKE::OWNER || CATALOG::CREATE_CATALOG";
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 2e476586f4..c703470aa4 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
@@ -95,7 +95,10 @@ import 
org.apache.gravitino.server.authorization.annotations.AuthorizationFullNa
 import 
org.apache.gravitino.server.authorization.annotations.AuthorizationMetadata;
 import 
org.apache.gravitino.server.authorization.annotations.AuthorizationObjectType;
 import 
org.apache.gravitino.server.authorization.annotations.AuthorizationRequest;
+import org.apache.gravitino.server.web.ServerHealth;
 import org.apache.gravitino.server.web.Utils;
+import 
org.apache.gravitino.server.web.filter.authorization.AuthorizationExecutor;
+import 
org.apache.gravitino.server.web.filter.authorization.AuthorizeExecutorFactory;
 import org.apache.gravitino.server.web.rest.CatalogOperations;
 import org.apache.gravitino.server.web.rest.JobOperations;
 import org.apache.gravitino.server.web.rest.MetadataObjectPolicyOperations;
@@ -123,6 +126,7 @@ import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
 import org.mockito.ArgumentCaptor;
 import org.mockito.ArgumentMatchers;
 import org.mockito.MockedStatic;
@@ -681,11 +685,13 @@ public class TestGravitinoInterceptionService {
     }
   }
 
+  /** Verifies that unexpected authorizer configuration errors retain 
server-error handling. */
   @Test
   public void testUnexpectedIllegalArgumentExceptionRemainsInternalError() 
throws Throwable {
     try (MockedStatic<PrincipalUtils> principalUtilsMocked = 
mockStatic(PrincipalUtils.class);
         MockedStatic<GravitinoAuthorizerProvider> mockStatic =
-            mockStatic(GravitinoAuthorizerProvider.class)) {
+            mockStatic(GravitinoAuthorizerProvider.class);
+        MockedStatic<ServerHealth> healths = mockStatic(ServerHealth.class)) {
       principalUtilsMocked
           .when(PrincipalUtils::getCurrentPrincipal)
           .thenReturn(new UserPrincipal("tester"));
@@ -694,8 +700,11 @@ public class TestGravitinoInterceptionService {
       MethodInvocation methodInvocation = mock(MethodInvocation.class);
       GravitinoAuthorizerProvider mockedProvider = 
mock(GravitinoAuthorizerProvider.class);
       
mockStatic.when(GravitinoAuthorizerProvider::getInstance).thenReturn(mockedProvider);
-      when(mockedProvider.getGravitinoAuthorizer())
-          .thenThrow(new IllegalArgumentException("Invalid authorizer 
configuration"));
+      IllegalArgumentException failure =
+          new IllegalArgumentException("Invalid authorizer configuration");
+      when(mockedProvider.getGravitinoAuthorizer()).thenThrow(failure);
+      ServerHealth health = mock(ServerHealth.class);
+      healths.when(ServerHealth::getInstance).thenReturn(health);
 
       GravitinoInterceptionService gravitinoInterceptionService =
           new GravitinoInterceptionService();
@@ -711,7 +720,112 @@ public class TestGravitinoInterceptionService {
       assertEquals(
           "Authorization failed due to system internal error. Please contact 
administrator.",
           errorResponse.getMessage());
+      assertEquals(RuntimeException.class.getSimpleName(), 
errorResponse.getType());
+      assertEquals(ErrorConstants.INTERNAL_ERROR_CODE, 
errorResponse.getCode());
       assertEquals(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), 
response.getStatus());
+      verify(health).recordFailure(failure);
+      verify(methodInvocation, never()).proceed();
+    }
+  }
+
+  /**
+   * Verifies that raw execution failures are not classified as malformed 
client input.
+   *
+   * @param failInExecutor whether the executor fails instead of the REST 
resource method
+   */
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  public void 
testExecutionIllegalArgumentExceptionRemainsInternalError(boolean 
failInExecutor)
+      throws Throwable {
+    try (MockedStatic<PrincipalUtils> principals = 
mockStatic(PrincipalUtils.class);
+        MockedStatic<AuthorizationUtils> authorization = 
mockStatic(AuthorizationUtils.class);
+        MockedStatic<AuthorizeExecutorFactory> factories =
+            mockStatic(AuthorizeExecutorFactory.class);
+        MockedStatic<ServerHealth> healths = mockStatic(ServerHealth.class)) {
+      principals.when(PrincipalUtils::getCurrentUserName).thenReturn("tester");
+      ServerHealth health = mock(ServerHealth.class);
+      healths.when(ServerHealth::getInstance).thenReturn(health);
+      AuthorizationExecutor executor = mock(AuthorizationExecutor.class);
+      factories
+          .when(
+              () ->
+                  AuthorizeExecutorFactory.create(
+                      any(), any(), any(), any(), any(), any(), any(), any(), 
any(), any()))
+          .thenReturn(executor);
+      Method method = TestOperations.class.getMethod("testMethod", 
String.class);
+      MethodInvocation invocation = mock(MethodInvocation.class);
+      when(invocation.getMethod()).thenReturn(method);
+      when(invocation.getArguments()).thenReturn(new Object[] {"metalake"});
+      IllegalArgumentException failure =
+          new IllegalArgumentException("Invalid backend configuration");
+      if (failInExecutor) {
+        when(executor.execute(any())).thenThrow(failure);
+      } else {
+        when(executor.execute(any())).thenReturn(true);
+        when(invocation.proceed()).thenThrow(failure);
+      }
+
+      try (Response response =
+          (Response)
+              new GravitinoInterceptionService()
+                  .getMethodInterceptors(method)
+                  .get(0)
+                  .invoke(invocation)) {
+        assertEquals(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), 
response.getStatus());
+        ErrorResponse error = (ErrorResponse) response.getEntity();
+        assertEquals(ErrorConstants.INTERNAL_ERROR_CODE, error.getCode());
+        assertEquals(RuntimeException.class.getSimpleName(), error.getType());
+        assertEquals(
+            "Authorization failed due to system internal error. Please contact 
administrator.",
+            error.getMessage());
+      }
+      verify(health).recordFailure(failure);
+      verify(executor).execute(any());
+      if (failInExecutor) {
+        verify(invocation, never()).proceed();
+      } else {
+        verify(invocation).proceed();
+      }
+    }
+  }
+
+  /**
+   * Verifies that blank Catalog names are rejected before the resource method 
is invoked.
+   *
+   * @param catalogName the whitespace-only Catalog name
+   */
+  @ParameterizedTest
+  @ValueSource(strings = {" ", "\t", "\n", " \t\n"})
+  public void testBlankCatalogNameReturnsBadRequest(String catalogName) throws 
Throwable {
+    try (MockedStatic<PrincipalUtils> principals = 
mockStatic(PrincipalUtils.class);
+        MockedStatic<GravitinoAuthorizerProvider> providers =
+            mockStatic(GravitinoAuthorizerProvider.class);
+        // Bypass checkCurrentUser's GravitinoEnv lookup to isolate 
catalog-name validation.
+        MockedStatic<AuthorizationUtils> authUtils = 
mockStatic(AuthorizationUtils.class)) {
+      principals.when(PrincipalUtils::getCurrentPrincipal).thenReturn(new 
UserPrincipal("tester"));
+      principals.when(PrincipalUtils::getCurrentUserName).thenReturn("tester");
+      GravitinoAuthorizerProvider provider = 
mock(GravitinoAuthorizerProvider.class);
+      
providers.when(GravitinoAuthorizerProvider::getInstance).thenReturn(provider);
+      
when(provider.getGravitinoAuthorizer()).thenReturn(mock(GravitinoAuthorizer.class));
+
+      Method method = CatalogOperations.class.getMethod("loadCatalog", 
String.class, String.class);
+      MethodInvocation invocation = mock(MethodInvocation.class);
+      when(invocation.getMethod()).thenReturn(method);
+      when(invocation.getArguments()).thenReturn(new Object[] {"testMetalake", 
catalogName});
+
+      Response response =
+          (Response)
+              new GravitinoInterceptionService()
+                  .getMethodInterceptors(method)
+                  .get(0)
+                  .invoke(invocation);
+
+      assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), 
response.getStatus());
+      ErrorResponse errorResponse = (ErrorResponse) response.getEntity();
+      assertEquals(ErrorConstants.ILLEGAL_ARGUMENTS_CODE, 
errorResponse.getCode());
+      assertEquals(IllegalArgumentException.class.getSimpleName(), 
errorResponse.getType());
+      assertEquals("Metadata object full name cannot be blank", 
errorResponse.getMessage());
+      verify(invocation, never()).proceed();
     }
   }
 
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestSemanticModelOperations.java
 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestSemanticModelOperations.java
index 7b321251ee..97cc2dd10a 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestSemanticModelOperations.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestSemanticModelOperations.java
@@ -73,6 +73,7 @@ import org.apache.gravitino.dto.semantic.DatasetDTO;
 import org.apache.gravitino.dto.semantic.SemanticModelDefinitionDTO;
 import org.apache.gravitino.exceptions.ConnectionFailedException;
 import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.gravitino.exceptions.IllegalMetadataObjectException;
 import org.apache.gravitino.exceptions.IllegalSemanticModelException;
 import org.apache.gravitino.exceptions.MetalakeNotInUseException;
 import org.apache.gravitino.exceptions.NoSuchSchemaException;
@@ -950,7 +951,7 @@ public class TestSemanticModelOperations extends 
BaseOperationsTest {
           response,
           Response.Status.BAD_REQUEST,
           ErrorConstants.ILLEGAL_ARGUMENTS_CODE,
-          IllegalArgumentException.class.getSimpleName(),
+          IllegalMetadataObjectException.class.getSimpleName(),
           "Metadata object full name cannot be blank");
       verifyNoModelWrites();
       verifyNoInteractions(tables, views);

Reply via email to