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

jerryshao pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/branch-1.3 by this push:
     new eb2cd9ce43 [#13360] fix(server): Avoid exposing missing metalakes in 
authorization errors (#13361) (#13362)
eb2cd9ce43 is described below

commit eb2cd9ce43a5237b6cc4e331542020b2df8745d9
Author: Qi Yu <[email protected]>
AuthorDate: Mon Sep 21 16:47:30 2026 +0800

    [#13360] fix(server): Avoid exposing missing metalakes in authorization 
errors (#13361) (#13362)
    
    ### What changes were proposed in this pull request?
    
    Backport of #13361 to branch-1.3.
    
    - Return the same 403 status, error code, type, and neutral message when
    a metalake is missing or the caller is not a member, including the
    dynamic metalake path used by lineage.
    - Use one membership message source so Iceberg and Lance REST callers no
    longer receive the misleading advice to add a user to a metalake that
    might not exist.
    - Remove the earlier service-admin bypass and existence probe; service
    admins retain the documented metalake permissions.
    - Test both failure paths for path and dynamic metalakes, and the shared
    membership message.
    
    ### Why are the changes needed?
    
    The old error directed callers to add themselves to a metalake that
    might not exist. Distinct responses for a missing metalake and an
    inaccessible one would also reveal metalake names. A custom authorizer
    may throw `NoSuchMetalakeException` directly; JCasbin reports both cases
    as failed membership. Both paths now have the same client-visible
    response.
    
    This addresses the misleading error in #13360. Returning `dropped=false`
    for a repeated delete requires a separate permission to probe metalake
    existence and is outside this change.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. Missing metalakes and failed membership checks return the same
    neutral 403 response. The dynamic lineage path now returns 403 for a
    missing metalake instead of 400. Iceberg and Lance REST callers also
    receive the neutral membership message.
    
    ### How was this patch tested?
    
    - `./gradlew :core:test --tests
    org.apache.gravitino.authorization.TestAuthorizationUtils :server:test
    --tests
    org.apache.gravitino.server.web.filter.TestGravitinoInterceptionService
    --tests
    
org.apache.gravitino.server.web.filter.authorization.TestLineageAuthorizationExecutor
    -PskipITs -PskipDockerTests=true`
    - `./gradlew :core:spotlessApply :server:spotlessApply`
---
 .../authorization/AuthorizationUtils.java          | 17 +++++--
 .../authorization/TestAuthorizationUtils.java      | 19 ++++++++
 .../web/filter/GravitinoInterceptionService.java   | 42 ++++++----------
 .../filter/TestGravitinoInterceptionService.java   | 57 +++++++++-------------
 .../TestLineageAuthorizationExecutor.java          | 48 +++++++-----------
 5 files changed, 88 insertions(+), 95 deletions(-)

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 0545baf3fa..ec1ea7fe7c 100644
--- 
a/core/src/main/java/org/apache/gravitino/authorization/AuthorizationUtils.java
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/AuthorizationUtils.java
@@ -125,12 +125,23 @@ public class AuthorizationUtils {
       String metalake, String user, AuthorizationRequestContext 
requestContext) {
     GravitinoAuthorizer authorizer = 
GravitinoEnv.getInstance().gravitinoAuthorizer();
     if (authorizer != null && !authorizer.isMetalakeUser(metalake, 
requestContext)) {
-      throw new ForbiddenException(
-          "Current user %s doesn't exist in the metalake %s, you should add 
the user to the metalake first",
-          user, metalake);
+      throw new ForbiddenException("%s", 
metalakeMembershipFailureMessage(metalake, user));
     }
   }
 
+  /**
+   * Returns a membership error that does not disclose whether the metalake 
exists.
+   *
+   * @param metalake The metalake name.
+   * @param user The current user name.
+   * @return A neutral membership error message.
+   */
+  public static String metalakeMembershipFailureMessage(String metalake, 
String user) {
+    return String.format(
+        "Current user %s is not a member of metalake %s, or the metalake does 
not exist",
+        user, metalake);
+  }
+
   public static NameIdentifier ofRole(String metalake, String role) {
     return NameIdentifier.of(
         metalake, Entity.SYSTEM_CATALOG_RESERVED_NAME, 
Entity.ROLE_SCHEMA_NAME, role);
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 0049a36ec2..ddaf6ae97f 100644
--- 
a/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationUtils.java
+++ 
b/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationUtils.java
@@ -40,6 +40,7 @@ import org.apache.gravitino.catalog.SchemaDispatcher;
 import org.apache.gravitino.catalog.TableDispatcher;
 import org.apache.gravitino.connector.BaseCatalog;
 import org.apache.gravitino.connector.authorization.AuthorizationPlugin;
+import org.apache.gravitino.exceptions.ForbiddenException;
 import org.apache.gravitino.exceptions.IllegalNameIdentifierException;
 import org.apache.gravitino.exceptions.IllegalNamespaceException;
 import org.apache.gravitino.meta.AuditInfo;
@@ -56,6 +57,24 @@ class TestAuthorizationUtils {
 
   String metalake = "metalake";
 
+  @Test
+  void testCheckCurrentUserUsesNeutralMembershipMessage() {
+    try (MockedStatic<GravitinoEnv> envMock = 
Mockito.mockStatic(GravitinoEnv.class)) {
+      GravitinoEnv env = Mockito.mock(GravitinoEnv.class);
+      GravitinoAuthorizer authorizer = Mockito.mock(GravitinoAuthorizer.class);
+      envMock.when(GravitinoEnv::getInstance).thenReturn(env);
+      Mockito.when(env.gravitinoAuthorizer()).thenReturn(authorizer);
+
+      ForbiddenException exception =
+          Assertions.assertThrows(
+              ForbiddenException.class,
+              () -> AuthorizationUtils.checkCurrentUser(metalake, "tester"));
+      Assertions.assertEquals(
+          "Current user tester is not a member of metalake metalake, or the 
metalake does not exist",
+          exception.getMessage());
+    }
+  }
+
   @Test
   void testCreateNameIdentifier() {
     NameIdentifier user = AuthorizationUtils.ofUser(metalake, "user");
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 a48e5e9d3e..215073d6e3 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
@@ -172,14 +172,7 @@ public class GravitinoInterceptionService implements 
InterceptionService {
           if (metalakeIdent != null) {
             authorizationMetalake = Optional.of(metalakeIdent.name());
             Optional<Response> validationFailure =
-                validateCurrentUser(
-                    metalakeIdent,
-                    authorizationRequestContext,
-                    expressionAnnotation,
-                    metadataContext,
-                    method,
-                    expression,
-                    false);
+                validateCurrentUser(metalakeIdent, 
authorizationRequestContext, method, expression);
             if (validationFailure.isPresent()) {
               return validationFailure.get();
             }
@@ -224,11 +217,8 @@ public class GravitinoInterceptionService implements 
InterceptionService {
                   validateCurrentUser(
                       NameIdentifier.of(dynamicMetalake.get()),
                       authorizationRequestContext,
-                      expressionAnnotation,
-                      metadataContext,
                       method,
-                      expression,
-                      true);
+                      expression);
               if (validationFailure.isPresent()) {
                 return validationFailure.get();
               }
@@ -287,28 +277,19 @@ public class GravitinoInterceptionService implements 
InterceptionService {
     private Optional<Response> validateCurrentUser(
         NameIdentifier metalakeIdent,
         AuthorizationRequestContext authorizationRequestContext,
-        AuthorizationExpression expressionAnnotation,
-        Map<Entity.EntityType, NameIdentifier> metadataContext,
         Method method,
-        String expression,
-        boolean dynamicMetalake) {
+        String expression) {
       String currentUser = PrincipalUtils.getCurrentUserName();
       try {
         AuthorizationUtils.checkCurrentUser(
             metalakeIdent.name(), currentUser, authorizationRequestContext);
       } catch (NoSuchMetalakeException e) {
+        // A custom authorizer may report a missing metalake directly; JCasbin 
reports it as
+        // non-membership instead.
         LOG.warn("Metalake {} does not exist when validating user {}", 
metalakeIdent, currentUser);
-        if (dynamicMetalake) {
-          return Optional.of(
-              Utils.illegalArguments(
-                  String.format(
-                      "job.namespace must identify an existing metalake: %s", 
metalakeIdent.name()),
-                  e));
-        }
-        // Not a real authz denial — metalake is absent, not forbidden. Skip 
event dispatch;
-        // HttpAuditFilter will emit a generic HttpRequestFailureEvent for 
this 403.
-        return Optional.of(
-            buildNoAuthResponse(expressionAnnotation, metadataContext, method, 
expression));
+        // A missing metalake is not an authorization denial, so skip the 
denial event. Return the
+        // same client-visible response as a failed membership check to avoid 
exposing existence.
+        return Optional.of(metalakeMembershipFailure(currentUser, 
metalakeIdent.name()));
       } catch (ForbiddenException ex) {
         LOG.warn(
             "User validation failed - User: {}, Metalake: {}, Reason: {}",
@@ -316,7 +297,7 @@ public class GravitinoInterceptionService implements 
InterceptionService {
             metalakeIdent.name(),
             ex.getMessage());
         dispatchAuthzDenialEvent(currentUser, metalakeIdent, method.getName(), 
expression);
-        return Optional.of(Utils.forbidden(ex.getMessage(), ex));
+        return Optional.of(metalakeMembershipFailure(currentUser, 
metalakeIdent.name()));
       } catch (Exception ex) {
         LOG.error(
             "Unexpected error during user validation - User: {}, Metalake: {}",
@@ -329,6 +310,11 @@ public class GravitinoInterceptionService implements 
InterceptionService {
       return Optional.empty();
     }
 
+    private Response metalakeMembershipFailure(String user, String metalake) {
+      return Utils.forbidden(
+          AuthorizationUtils.metalakeMembershipFailureMessage(metalake, user), 
null);
+    }
+
     private Response buildNoAuthResponse(
         AuthorizationExpression expressionAnnotation,
         Map<Entity.EntityType, NameIdentifier> metadataContext,
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 daa2521910..92d4617bfd 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
@@ -418,52 +418,43 @@ public class TestGravitinoInterceptionService {
   }
 
   @Test
-  public void testMetalakeNotExist() throws Throwable {
+  public void testMissingAndInaccessibleMetalakeHaveSameResponse() 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");
-
-      MethodInvocation methodInvocation = mock(MethodInvocation.class);
-      GravitinoAuthorizerProvider mockedProvider = 
mock(GravitinoAuthorizerProvider.class);
-      
authorizerMocked.when(GravitinoAuthorizerProvider::getInstance).thenReturn(mockedProvider);
-      when(mockedProvider.getGravitinoAuthorizer()).thenReturn(new 
MockGravitinoAuthorizer());
-
-      // Mock AuthorizationUtils.checkCurrentUser to throw 
NoSuchMetalakeException
       authorizationUtilsMocked
           .when(
               () ->
                   AuthorizationUtils.checkCurrentUser(
                       ArgumentMatchers.any(), ArgumentMatchers.any(), 
ArgumentMatchers.any()))
-          .thenThrow(new NoSuchMetalakeException("Metalake nonExistentMetalake 
does not exist"));
+          .thenThrow(new NoSuchMetalakeException("Metalake target does not 
exist"))
+          .thenThrow(new ForbiddenException("User is not a member of target"));
+      authorizationUtilsMocked
+          .when(() -> 
AuthorizationUtils.metalakeMembershipFailureMessage("target", "tester"))
+          .thenCallRealMethod();
 
-      GravitinoInterceptionService gravitinoInterceptionService =
-          new GravitinoInterceptionService();
-      Class<TestOperations> testOperationsClass = TestOperations.class;
-      Method[] methods = testOperationsClass.getMethods();
-      Method testMethod = methods[0];
-      List<MethodInterceptor> methodInterceptors =
-          gravitinoInterceptionService.getMethodInterceptors(testMethod);
-      MethodInterceptor methodInterceptor = methodInterceptors.get(0);
+      Method method = TestOperations.class.getMethods()[0];
+      MethodInvocation invocation = mock(MethodInvocation.class);
+      when(invocation.getMethod()).thenReturn(method);
+      when(invocation.getArguments()).thenReturn(new Object[] {"target"});
+      MethodInterceptor interceptor =
+          new 
GravitinoInterceptionService().getMethodInterceptors(method).get(0);
 
-      // Test with non-existent metalake
-      when(methodInvocation.getMethod()).thenReturn(testMethod);
-      when(methodInvocation.getArguments()).thenReturn(new Object[] 
{"nonExistentMetalake"});
-      Response response = (Response) 
methodInterceptor.invoke(methodInvocation);
+      Response missingResponse = (Response) interceptor.invoke(invocation);
+      Response inaccessibleResponse = (Response) 
interceptor.invoke(invocation);
 
-      // Verify that a 403 Forbidden response is returned
-      assertEquals(Response.Status.FORBIDDEN.getStatusCode(), 
response.getStatus());
-      ErrorResponse errorResponse = (ErrorResponse) response.getEntity();
+      assertEquals(Response.Status.FORBIDDEN.getStatusCode(), 
missingResponse.getStatus());
+      assertEquals(missingResponse.getStatus(), 
inaccessibleResponse.getStatus());
+      ErrorResponse missingError = (ErrorResponse) missingResponse.getEntity();
+      ErrorResponse inaccessibleError = (ErrorResponse) 
inaccessibleResponse.getEntity();
+      assertEquals(missingError.getCode(), inaccessibleError.getCode());
+      assertEquals(missingError.getType(), inaccessibleError.getType());
       assertEquals(
-          "User 'tester' is not authorized to perform operation 'testMethod' 
on "
-              + "metadata 'nonExistentMetalake'",
-          errorResponse.getMessage());
+          "Current user tester is not a member of metalake target, or the 
metalake does not exist",
+          missingError.getMessage());
+      assertEquals(missingError.getMessage(), inaccessibleError.getMessage());
+      verify(invocation, never()).proceed();
     }
   }
 
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/filter/authorization/TestLineageAuthorizationExecutor.java
 
b/server/src/test/java/org/apache/gravitino/server/web/filter/authorization/TestLineageAuthorizationExecutor.java
index 2829baf2ca..518e09d6d1 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/filter/authorization/TestLineageAuthorizationExecutor.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/filter/authorization/TestLineageAuthorizationExecutor.java
@@ -110,7 +110,7 @@ class TestLineageAuthorizationExecutor {
   }
 
   @Test
-  void testInterceptorRejectsUserOutsideDynamicMetalake() throws Throwable {
+  void testMissingAndInaccessibleDynamicMetalakeHaveSameResponse() throws 
Throwable {
     MethodInvocation invocation = invocation(event());
 
     try (MockedStatic<PrincipalUtils> principalUtils = 
mockStatic(PrincipalUtils.class);
@@ -123,38 +123,24 @@ class TestLineageAuthorizationExecutor {
               () ->
                   AuthorizationUtils.checkCurrentUser(
                       eq(METALAKE), eq("tester"), 
any(AuthorizationRequestContext.class)))
+          .thenThrow(new NoSuchMetalakeException("Metalake does not exist"))
           .thenThrow(new ForbiddenException("User tester is not a member"));
-
-      Response response = (Response) lineageInterceptor().invoke(invocation);
-
-      Assertions.assertEquals(Response.Status.FORBIDDEN.getStatusCode(), 
response.getStatus());
-      verify(invocation, never()).proceed();
-    }
-  }
-
-  @Test
-  void testRejectNonexistentDynamicMetalakeAsBadRequest() throws Throwable {
-    MethodInvocation invocation = invocation(event());
-
-    try (MockedStatic<PrincipalUtils> principalUtils = 
mockStatic(PrincipalUtils.class);
-        MockedStatic<AuthorizationUtils> authorizationUtils =
-            mockStatic(AuthorizationUtils.class)) {
-      
principalUtils.when(PrincipalUtils::getCurrentPrincipal).thenReturn(principal());
-      
principalUtils.when(PrincipalUtils::getCurrentUserName).thenReturn("tester");
       authorizationUtils
-          .when(
-              () ->
-                  AuthorizationUtils.checkCurrentUser(
-                      eq(METALAKE), eq("tester"), 
any(AuthorizationRequestContext.class)))
-          .thenThrow(new NoSuchMetalakeException("Metalake does not exist"));
-
-      Response response = (Response) lineageInterceptor().invoke(invocation);
-
-      Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), 
response.getStatus());
-      ErrorResponse errorResponse = (ErrorResponse) response.getEntity();
-      Assertions.assertTrue(
-          errorResponse.getMessage().contains("job.namespace"),
-          "The response should identify the invalid field");
+          .when(() -> 
AuthorizationUtils.metalakeMembershipFailureMessage("metalake", "tester"))
+          .thenCallRealMethod();
+
+      MethodInterceptor interceptor = lineageInterceptor();
+      Response missingResponse = (Response) interceptor.invoke(invocation);
+      Response inaccessibleResponse = (Response) 
interceptor.invoke(invocation);
+
+      Assertions.assertEquals(
+          Response.Status.FORBIDDEN.getStatusCode(), 
missingResponse.getStatus());
+      Assertions.assertEquals(missingResponse.getStatus(), 
inaccessibleResponse.getStatus());
+      ErrorResponse missingError = (ErrorResponse) missingResponse.getEntity();
+      ErrorResponse inaccessibleError = (ErrorResponse) 
inaccessibleResponse.getEntity();
+      Assertions.assertEquals(missingError.getCode(), 
inaccessibleError.getCode());
+      Assertions.assertEquals(missingError.getType(), 
inaccessibleError.getType());
+      Assertions.assertEquals(missingError.getMessage(), 
inaccessibleError.getMessage());
       verify(invocation, never()).proceed();
     }
   }

Reply via email to