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 1deb450626 [Cherry-pick to branch-1.3] [#13870] fix(iceberg-rest): 
Load real tables named like metadata tables in nested namespaces (#13874) 
(#14028)
1deb450626 is described below

commit 1deb4506269affe565fbd192c20388a8d21e31d6
Author: YangJie <[email protected]>
AuthorDate: Tue Oct 20 22:47:41 2026 -0400

    [Cherry-pick to branch-1.3] [#13870] fix(iceberg-rest): Load real tables 
named like metadata tables in nested namespaces (#13874) (#14028)
    
    ### What changes were proposed in this pull request?
    
    Cherry-pick of #13874 to branch-1.3. `LoadTableAuthzHandler` treats a
    multi-level request with a metadata-table name as a metadata-table
    request only when the backend does not resolve it to a real table,
    through the new `IcebergCatalogWrapper.resolvesToMetadataTable`.
    
    The automatic cherry-pick in #13915 conflicted only in the imports of
    `IcebergCatalogWrapper`: branch-1.3 imports `NoSuchViewException` where
    #13874 added `NoSuchTableException`. This PR keeps both; the rest
    applies unchanged. It supersedes #13915.
    
    ### Why are the changes needed?
    
    Backport the fix for #13870 to branch-1.3, which has hierarchical
    schemas and the same handler.
    
    Fix: #13870
    
    ### Does this PR introduce _any_ user-facing change?
    
    Tables with metadata-table names in nested namespaces can be loaded, as
    in #13874.
    
    ### How was this patch tested?
    
    The `TestLoadTableAuthzHandler` cases from #13874; on branch-1.3 the
    real-table and load-failure cases fail against the pre-fix handler.
---
 .../iceberg/common/ops/IcebergCatalogWrapper.java  |  15 ++
 .../server/web/filter/LoadTableAuthzHandler.java   |  47 ++++-
 .../web/filter/TestLoadTableAuthzHandler.java      | 219 +++++++++++++++++++++
 3 files changed, 274 insertions(+), 7 deletions(-)

diff --git 
a/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ops/IcebergCatalogWrapper.java
 
b/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ops/IcebergCatalogWrapper.java
index 2b122ab49d..706b8f71e2 100644
--- 
a/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ops/IcebergCatalogWrapper.java
+++ 
b/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ops/IcebergCatalogWrapper.java
@@ -38,6 +38,7 @@ import org.apache.gravitino.utils.ClassUtils;
 import org.apache.gravitino.utils.IsolatedClassLoader;
 import org.apache.hadoop.conf.Configuration;
 import org.apache.hadoop.security.UserGroupInformation;
+import org.apache.iceberg.BaseMetadataTable;
 import org.apache.iceberg.BaseTable;
 import org.apache.iceberg.CatalogUtil;
 import org.apache.iceberg.TableMetadata;
@@ -48,6 +49,7 @@ import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.SupportsNamespaces;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.catalog.ViewCatalog;
+import org.apache.iceberg.exceptions.NoSuchTableException;
 import org.apache.iceberg.exceptions.NoSuchViewException;
 import org.apache.iceberg.io.FileIO;
 import org.apache.iceberg.io.ResolvingFileIO;
@@ -330,6 +332,19 @@ public class IcebergCatalogWrapper implements 
AutoCloseable {
     return getCatalog().tableExists(tableIdentifier);
   }
 
+  /**
+   * Returns whether the identifier resolves to a metadata table rather than 
to a table with that
+   * name. For example, {@code db.t.files} resolves to the files table of 
{@code db.t} when the
+   * namespace {@code db.t} has no table {@code files}.
+   *
+   * @param tableIdentifier the table identifier
+   * @return true if loading the identifier returns a metadata table
+   * @throws NoSuchTableException if neither exists
+   */
+  public boolean resolvesToMetadataTable(TableIdentifier tableIdentifier) {
+    return getCatalog().loadTable(tableIdentifier) instanceof 
BaseMetadataTable;
+  }
+
   public ListTablesResponse listTable(Namespace namespace) {
     return CatalogHandlers.listTables(getCatalog(), namespace);
   }
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/LoadTableAuthzHandler.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/LoadTableAuthzHandler.java
index f715b52ed0..fe24ca53cb 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/LoadTableAuthzHandler.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/LoadTableAuthzHandler.java
@@ -35,6 +35,8 @@ import org.apache.iceberg.MetadataTableType;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.exceptions.NoSuchTableException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * Handler for LOAD_TABLE operations. Validates that the requested entity is 
not a metadata table,
@@ -49,6 +51,8 @@ import org.apache.iceberg.exceptions.NoSuchTableException;
  * clients such as Trino/Spark).
  */
 public class LoadTableAuthzHandler implements AuthorizationHandler {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(LoadTableAuthzHandler.class);
   private final AuthorizationExpression authorizationExpression;
   private final Parameter[] parameters;
   private final Object[] args;
@@ -72,11 +76,6 @@ public class LoadTableAuthzHandler implements 
AuthorizationHandler {
     String tableName = loadTarget.name();
     Namespace namespace = loadTarget.namespace();
 
-    // Validate that this is not a metadata table access
-    if (isMetadataTable(tableName, namespace)) {
-      throw new NoSuchTableException("Table %s not found", tableName);
-    }
-
     LoadContext loadContext =
         IcebergLoadAuthzHandlerHelper.extractLoadContext(
             nameIdentifierMap,
@@ -89,6 +88,12 @@ public class LoadTableAuthzHandler implements 
AuthorizationHandler {
     // 2. Let request proceed - the actual loadTable() call will handle 
non-existence
     IcebergCatalogWrapper catalogWrapper =
         IcebergLoadAuthzHandlerHelper.getCatalogWrapper(loadContext.catalog());
+
+    // Validate that this is not a metadata table access
+    if (isMetadataTable(tableName, namespace, catalogWrapper)) {
+      throw new NoSuchTableException("Table %s not found", tableName);
+    }
+
     TableIdentifier tableIdentifier = TableIdentifier.of(namespace, tableName);
 
     NameIdentifier tableId =
@@ -144,9 +149,11 @@ public class LoadTableAuthzHandler implements 
AuthorizationHandler {
    *
    * @param tableName The table name to check
    * @param namespace The Iceberg namespace of the table
+   * @param catalogWrapper the catalog wrapper used to resolve the table
    * @return true if this is a metadata table access, false otherwise
    */
-  private boolean isMetadataTable(String tableName, Namespace namespace) {
+  private boolean isMetadataTable(
+      String tableName, Namespace namespace, IcebergCatalogWrapper 
catalogWrapper) {
     MetadataTableType metadataTableType = MetadataTableType.from(tableName);
     if (metadataTableType == null) {
       return false;
@@ -154,6 +161,32 @@ public class LoadTableAuthzHandler implements 
AuthorizationHandler {
 
     // Metadata tables have namespace length > 1 (e.g., catalog.db.table has 3 
levels).
     // Regular tables have namespace length = 1 (e.g., catalog.db has 2 
levels, but we get "db").
-    return namespace.levels().length > 1;
+    if (namespace.levels().length <= 1) {
+      return false;
+    }
+
+    // A table in a hierarchical schema can have a metadata-table name, so 
only treat the
+    // request as a metadata-table access when the backend does not resolve it 
to a real table.
+    // tableExists() cannot tell the two apart: backends resolve [db, t].files 
to the metadata
+    // table of db.t as well.
+    try {
+      if (!catalogWrapper.namespaceExists(namespace)) {
+        return true;
+      }
+    } catch (Exception e) {
+      // Some backends reject multi-level namespaces. Keep the 404 the client 
falls back on.
+      LOG.debug("Failed to check whether namespace {} exists", namespace, e);
+      return true;
+    }
+
+    try {
+      return 
catalogWrapper.resolvesToMetadataTable(TableIdentifier.of(namespace, 
tableName));
+    } catch (NoSuchTableException e) {
+      return true;
+    } catch (RuntimeException e) {
+      // Let the real load report the failure. A 404 here would make the 
client fall back and
+      // read the metadata table of another table.
+      return false;
+    }
   }
 }
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestLoadTableAuthzHandler.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestLoadTableAuthzHandler.java
new file mode 100644
index 0000000000..976fc7fcda
--- /dev/null
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestLoadTableAuthzHandler.java
@@ -0,0 +1,219 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package org.apache.gravitino.server.web.filter;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableMap;
+import java.lang.reflect.Method;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.iceberg.service.CatalogWrapperForREST;
+import org.apache.gravitino.iceberg.service.IcebergCatalogWrapperManager;
+import 
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
+import org.apache.gravitino.iceberg.service.provider.IcebergConfigProvider;
+import org.apache.gravitino.server.ServerConfig;
+import org.apache.gravitino.server.authorization.GravitinoAuthorizerProvider;
+import 
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
+import 
org.apache.gravitino.server.authorization.annotations.AuthorizationMetadata;
+import 
org.apache.gravitino.server.authorization.annotations.IcebergAuthorizationMetadata;
+import 
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.exceptions.NoSuchTableException;
+import org.apache.iceberg.exceptions.ServiceUnavailableException;
+import org.apache.iceberg.inmemory.InMemoryCatalog;
+import org.apache.iceberg.types.Types;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+/** Test for {@link LoadTableAuthzHandler}. */
+public class TestLoadTableAuthzHandler {
+
+  private static final String TEST_METALAKE = "test_metalake";
+  private static final String TEST_CATALOG = "test_catalog";
+  private static final String TEST_SCHEMA = "test_schema";
+  private static final Schema SCHEMA =
+      new Schema(Types.NestedField.required(1, "id", Types.LongType.get()));
+
+  @BeforeAll
+  public static void initAuthorizer() {
+    GravitinoAuthorizerProvider.getInstance().initialize(new ServerConfig());
+  }
+
+  @Test
+  public void 
testLoadTableDoesNotRejectMetadataTableLikeNameInHierarchicalSchema()
+      throws Exception {
+    // The hierarchical schema "a.b" holds a real table "files". A table a.b 
exists too, so
+    // a.b.files could also name its metadata table; the real table must win.
+    InMemoryCatalog catalog = newCatalog();
+    catalog.createNamespace(Namespace.of("a"));
+    catalog.createNamespace(Namespace.of("a", "b"));
+    catalog.createTable(TableIdentifier.of(Namespace.of("a"), "b"), SCHEMA);
+    catalog.createTable(TableIdentifier.of(Namespace.of("a", "b"), "files"), 
SCHEMA);
+    resetContext(catalog);
+
+    LoadTableAuthzHandler handler =
+        createHandler(new Object[] {TEST_CATALOG + "/", "a%1Fb", "files"});
+
+    // The real table must reach authorization instead of being rejected as a 
metadata table.
+    assertDoesNotThrow(() -> handler.process(baseIdentifierMap()));
+  }
+
+  @Test
+  public void testLoadTableRejectsMetadataTableWhenNamespaceHasNoSuchTable() 
throws Exception {
+    // Both the namespace [db, t] and the table db.t exist, but [db, t] has no 
table "files",
+    // so db.t.files is a metadata-table request and must get 404 for the 
client fallback.
+    InMemoryCatalog catalog = newCatalog();
+    catalog.createNamespace(Namespace.of("db"));
+    catalog.createNamespace(Namespace.of("db", "t"));
+    catalog.createTable(TableIdentifier.of(Namespace.of("db"), "t"), SCHEMA);
+    resetContext(catalog);
+
+    LoadTableAuthzHandler handler =
+        createHandler(new Object[] {TEST_CATALOG + "/", "db%1Ft", "files"});
+
+    assertThrows(NoSuchTableException.class, () -> 
handler.process(baseIdentifierMap()));
+  }
+
+  @Test
+  public void testLoadTableStillRejectsGenuineMetadataTableRequests() throws 
Exception {
+    IcebergCatalogWrapperManager wrapperManager = 
Mockito.mock(IcebergCatalogWrapperManager.class);
+    CatalogWrapperForREST catalogWrapper = 
Mockito.mock(CatalogWrapperForREST.class);
+    
when(wrapperManager.getCatalogWrapper(TEST_CATALOG)).thenReturn(catalogWrapper);
+    // [db, table] is not an existing namespace - this is a metadata-table 
request.
+    when(catalogWrapper.namespaceExists(Namespace.of("db", 
"t"))).thenReturn(false);
+    resetContext(wrapperManager);
+
+    LoadTableAuthzHandler handler =
+        createHandler(new Object[] {TEST_CATALOG + "/", "db%1Ft", "files"});
+
+    assertThrows(NoSuchTableException.class, () -> 
handler.process(baseIdentifierMap()));
+    verify(catalogWrapper, 
never()).resolvesToMetadataTable(any(TableIdentifier.class));
+  }
+
+  @Test
+  public void testBackendFailureKeepsMetadataTableAnswer404() throws Exception 
{
+    IcebergCatalogWrapperManager wrapperManager = 
Mockito.mock(IcebergCatalogWrapperManager.class);
+    CatalogWrapperForREST catalogWrapper = 
Mockito.mock(CatalogWrapperForREST.class);
+    
when(wrapperManager.getCatalogWrapper(TEST_CATALOG)).thenReturn(catalogWrapper);
+    when(catalogWrapper.namespaceExists(Namespace.of("db", "t")))
+        .thenThrow(new RuntimeException("remote catalog unavailable"));
+    resetContext(wrapperManager);
+
+    LoadTableAuthzHandler handler =
+        createHandler(new Object[] {TEST_CATALOG + "/", "db%1Ft", "files"});
+
+    // A failing existence check must keep the guaranteed 404 for 
metadata-shaped requests,
+    // not surface as a server error.
+    assertThrows(NoSuchTableException.class, () -> 
handler.process(baseIdentifierMap()));
+  }
+
+  @Test
+  public void testLoadFailureOfExistingNamespaceGoesToAuthorization() throws 
Exception {
+    IcebergCatalogWrapperManager wrapperManager = 
Mockito.mock(IcebergCatalogWrapperManager.class);
+    CatalogWrapperForREST catalogWrapper = 
Mockito.mock(CatalogWrapperForREST.class);
+    
when(wrapperManager.getCatalogWrapper(TEST_CATALOG)).thenReturn(catalogWrapper);
+    when(catalogWrapper.namespaceExists(Namespace.of("a", 
"b"))).thenReturn(true);
+    when(catalogWrapper.resolvesToMetadataTable(any(TableIdentifier.class)))
+        .thenThrow(new ServiceUnavailableException("remote catalog 
unavailable"));
+    resetContext(wrapperManager);
+
+    LoadTableAuthzHandler handler =
+        createHandler(new Object[] {TEST_CATALOG + "/", "a%1Fb", "files"});
+
+    // A 404 would make the client fall back to the metadata table of a.b, so 
the request goes on
+    // to authorization and the real load reports the failure.
+    assertDoesNotThrow(() -> handler.process(baseIdentifierMap()));
+  }
+
+  private static LoadTableAuthzHandler createHandler(Object[] args) throws 
Exception {
+    Method method =
+        TestOperations.class.getMethod("loadTable", String.class, 
String.class, String.class);
+    return new LoadTableAuthzHandler(
+        method.getAnnotation(AuthorizationExpression.class), 
method.getParameters(), args);
+  }
+
+  private static Map<Entity.EntityType, NameIdentifier> baseIdentifierMap() {
+    Map<Entity.EntityType, NameIdentifier> nameIdentifierMap = new HashMap<>();
+    nameIdentifierMap.put(Entity.EntityType.METALAKE, 
NameIdentifierUtil.ofMetalake(TEST_METALAKE));
+    nameIdentifierMap.put(
+        Entity.EntityType.CATALOG, NameIdentifierUtil.ofCatalog(TEST_METALAKE, 
TEST_CATALOG));
+    nameIdentifierMap.put(
+        Entity.EntityType.SCHEMA,
+        NameIdentifierUtil.ofSchema(TEST_METALAKE, TEST_CATALOG, TEST_SCHEMA));
+    return nameIdentifierMap;
+  }
+
+  private static InMemoryCatalog newCatalog() {
+    InMemoryCatalog catalog = new InMemoryCatalog();
+    catalog.initialize("memory", ImmutableMap.of());
+    return catalog;
+  }
+
+  /** Points the handler at a catalog wrapper backed by {@code catalog}. */
+  private static void resetContext(InMemoryCatalog catalog) {
+    IcebergCatalogWrapperManager wrapperManager = 
Mockito.mock(IcebergCatalogWrapperManager.class);
+    CatalogWrapperForREST catalogWrapper = 
Mockito.mock(CatalogWrapperForREST.class);
+    
when(wrapperManager.getCatalogWrapper(TEST_CATALOG)).thenReturn(catalogWrapper);
+    when(catalogWrapper.getCatalog()).thenReturn(catalog);
+    when(catalogWrapper.namespaceExists(any(Namespace.class)))
+        .thenAnswer(invocation -> 
catalog.namespaceExists(invocation.getArgument(0)));
+    
when(catalogWrapper.resolvesToMetadataTable(any(TableIdentifier.class))).thenCallRealMethod();
+    resetContext(wrapperManager);
+  }
+
+  private static void resetContext(IcebergCatalogWrapperManager 
wrapperManager) {
+    IcebergConfigProvider configProvider = 
Mockito.mock(IcebergConfigProvider.class);
+    when(configProvider.getMetalakeName()).thenReturn(TEST_METALAKE);
+    when(configProvider.getDefaultCatalogName()).thenReturn(TEST_CATALOG);
+    IcebergRESTServerContext.create(configProvider, false, false, true, 
wrapperManager);
+  }
+
+  /** Test operations class to provide method annotations for testing. */
+  @SuppressWarnings("unused")
+  public static class TestOperations {
+    @AuthorizationExpression(
+        expression = 
AuthorizationExpressionConstants.LOAD_TABLE_AUTHORIZATION_EXPRESSION,
+        allowCheckExistence =
+            AuthorizationExpressionConstants
+                .ICEBERG_TABLE_EXISTS_SECONDARY_AUTHORIZATION_EXPRESSION,
+        accessMetadataType = MetadataObject.Type.TABLE)
+    public void loadTable(
+        @AuthorizationMetadata(type = Entity.EntityType.CATALOG) String prefix,
+        @AuthorizationMetadata(type = Entity.EntityType.SCHEMA) String 
namespace,
+        @IcebergAuthorizationMetadata(type = 
IcebergAuthorizationMetadata.RequestType.LOAD_TABLE)
+            @AuthorizationMetadata(type = Entity.EntityType.TABLE)
+            String table) {
+      // Test method
+    }
+  }
+}

Reply via email to