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 ce08516a59 [#9970] feat(server): Gravitino REST supports the 
hierarchical schema (#11175)
ce08516a59 is described below

commit ce08516a596d9d2dde24d8ec7b5fb62056bb244c
Author: roryqi <[email protected]>
AuthorDate: Fri May 22 15:50:07 2026 +0800

    [#9970] feat(server): Gravitino REST supports the hierarchical schema 
(#11175)
    
    ### What changes were proposed in this pull request?
    
    Expose hierarchical schema support through the **Gravitino
    server REST API**, building on the already-merged core hierarchical
    schema (#11036, #11074) and Iceberg REST support (#11118).
    
    - **`SchemaOperations`**: add a `parentSchema` query parameter to list
    schemas; scope create-schema authorization at the `SCHEMA` level. A new
    **`CreateSchemaAuthorizationExecutor`** injects the parent schema for
    nested names (`A:B:C`) so the authorization inheritance chain (`A:B → A
    → CATALOG`) is evaluated. Wired through `AuthorizeExecutorFactory` /
    `AuthorizationRequest` (new `CREATE_SCHEMA` type).
    - **`IcebergCatalogCapability`**: accept logical nested schema names
    using the configured external separator (e.g. `A:B:C`), rejecting empty
    segments; `IcebergCatalog` passes the configured separator.
    - **`IcebergCatalogOperations.listSchemas`**: list schemas under a
    parent namespace and convert multi-level Iceberg namespaces back to
    logical names.
    
    ### Why are the changes needed?
    
    This is the remaining "Gravitino REST" portion of the
    hierarchical-schema feature; the core and Iceberg-REST parts were
    already split out and merged.
    
    Fix: #9970
    
    ### Does this PR introduce _any_ user-facing change?
    
    - `GET .../schemas` accepts an optional `parentSchema` query parameter.
    - Creating a schema with a separator-delimited name (default `:`)
    creates a nested schema; the create-schema privilege can now be granted
    at SCHEMA scope and inherits to descendants.
    
    ### How was this patch tested?
    
    - New unit tests: `TestIcebergCatalogCapability`,
    `TestIcebergCatalogOperations` (listSchemas), `TestSchemaOperations`
    (parentSchema listing).
    - New `HierarchicalSchemaAuthorizationIT` covering end-to-end nested schema
    creation, authorization inheritance, and ownership on an Iceberg
    catalog.
    - `./gradlew test -PskipITs` for the affected modules passes; spotless
    clean.
---
 .../java/org/apache/gravitino/SupportsSchemas.java |  22 ++
 .../catalog/lakehouse/iceberg/IcebergCatalog.java  |   3 +-
 .../iceberg/IcebergCatalogCapability.java          |  55 ++++
 .../iceberg/IcebergCatalogOperations.java          |  28 +-
 .../iceberg/TestIcebergCatalogCapability.java      | 106 +++++++
 .../iceberg/TestIcebergCatalogOperations.java      |  55 +++-
 clients/client-java/build.gradle.kts               |   5 +
 .../apache/gravitino/client/BaseSchemaCatalog.java |  42 ++-
 .../gravitino/client/TestRelationalCatalog.java    |  37 +++
 .../HierarchicalSchemaAuthorizationIT.java         | 330 +++++++++++++++++++++
 .../gravitino/api/supports_schemas.py              |  18 +-
 .../gravitino/client/base_schema_catalog.py        |  21 +-
 .../tests/unittests/test_base_schema_catalog.py    | 100 +++++++
 .../catalog/SchemaOperationDispatcher.java         |  41 +++
 .../gravitino/hook/SchemaHookDispatcher.java       | 108 ++++++-
 .../gravitino/utils/HierarchicalSchemaUtil.java    |  17 +-
 .../catalog/TestSchemaOperationDispatcher.java     |  55 ++++
 .../gravitino/hook/TestSchemaHookDispatcher.java   | 133 ++++++++-
 .../gravitino/integration/test/util/BaseIT.java    |   9 +-
 .../annotations/AuthorizationRequest.java          |   3 +-
 .../authorization/AuthorizeExecutorFactory.java    |   2 +
 .../CreateSchemaAuthorizationExecutor.java         | 105 +++++++
 .../server/web/rest/SchemaOperations.java          |  55 +++-
 .../server/web/rest/TestSchemaOperations.java      |  40 +++
 24 files changed, 1334 insertions(+), 56 deletions(-)

diff --git a/api/src/main/java/org/apache/gravitino/SupportsSchemas.java 
b/api/src/main/java/org/apache/gravitino/SupportsSchemas.java
index 96fe10da07..42284ed24d 100644
--- a/api/src/main/java/org/apache/gravitino/SupportsSchemas.java
+++ b/api/src/main/java/org/apache/gravitino/SupportsSchemas.java
@@ -46,6 +46,28 @@ public interface SupportsSchemas {
    */
   String[] listSchemas() throws NoSuchCatalogException;
 
+  /**
+   * List the schemas directly under the given parent schema.
+   *
+   * <p>This is only meaningful for catalogs that support hierarchical 
(multi-level) schemas, such
+   * as an Iceberg catalog accessed through the Gravitino REST server with a 
configured schema
+   * separator. For example, when the schemas {@code a}, {@code a:b} and 
{@code a:b:c} exist, this
+   * method invoked with parent {@code a:b} returns {@code [a:b:c]}. For a 
flat catalog, or a parent
+   * schema that has no children, an empty array is returned.
+   *
+   * @param parentSchema The parent (possibly hierarchical) schema name whose 
direct children are
+   *     listed, e.g. {@code "a"} or {@code "a:b"}. Must not be null or blank.
+   * @return An array of schema names directly under the given parent schema.
+   * @throws IllegalArgumentException If {@code parentSchema} is null or blank.
+   * @throws NoSuchCatalogException If the catalog does not exist.
+   * @throws NoSuchSchemaException If the parent schema does not exist.
+   */
+  default String[] listSchemas(String parentSchema)
+      throws NoSuchCatalogException, NoSuchSchemaException {
+    throw new UnsupportedOperationException(
+        "Listing schemas under a parent schema is not supported by this 
catalog");
+  }
+
   /**
    * Check if a schema exists.
    *
diff --git 
a/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalog.java
 
b/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalog.java
index 9dc1d53414..653831464d 100644
--- 
a/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalog.java
+++ 
b/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalog.java
@@ -31,6 +31,7 @@ import org.apache.gravitino.connector.capability.Capability;
 import org.apache.gravitino.credential.CredentialConstants;
 import org.apache.gravitino.credential.JdbcCredential;
 import org.apache.gravitino.rel.ViewCatalog;
+import org.apache.gravitino.utils.HierarchicalSchemaUtil;
 
 /** Implementation of an Apache Iceberg catalog in Apache Gravitino. */
 public class IcebergCatalog extends BaseCatalog<IcebergCatalog> {
@@ -71,7 +72,7 @@ public class IcebergCatalog extends 
BaseCatalog<IcebergCatalog> {
 
   @Override
   public Capability newCapability() {
-    return new IcebergCatalogCapability();
+    return new 
IcebergCatalogCapability(HierarchicalSchemaUtil.schemaSeparator());
   }
 
   @Override
diff --git 
a/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalogCapability.java
 
b/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalogCapability.java
index 90a0906629..2bac6ed4db 100644
--- 
a/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalogCapability.java
+++ 
b/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalogCapability.java
@@ -20,12 +20,67 @@ package org.apache.gravitino.catalog.lakehouse.iceberg;
 
 import org.apache.gravitino.connector.capability.Capability;
 import org.apache.gravitino.connector.capability.CapabilityResult;
+import org.apache.gravitino.utils.HierarchicalSchemaUtil;
 
 public class IcebergCatalogCapability implements Capability {
+
+  private final String schemaSeparator;
+
+  /**
+   * Creates a capability with the given external schema separator.
+   *
+   * @param schemaSeparator the external separator used in logical schema 
names (e.g. {@code ":"})
+   */
+  public IcebergCatalogCapability(String schemaSeparator) {
+    this.schemaSeparator = schemaSeparator;
+  }
+
   @Override
   public CapabilityResult columnDefaultValue() {
     // Iceberg column default value is WIP, see
     // https://github.com/apache/iceberg/pull/4525
     return CapabilityResult.unsupported("Iceberg does not support column 
default value.");
   }
+
+  /**
+   * Validates the schema name specification for Iceberg.
+   *
+   * <p>For {@link Scope#SCHEMA}, Iceberg accepts:
+   *
+   * <ul>
+   *   <li>Regular flat schema names matching the default name pattern.
+   *   <li>Logical hierarchical schema names using the configured external 
separator (e.g. {@code
+   *       "A:B:C"} with separator {@code ":"}). Each segment must be 
non-empty and individually
+   *       satisfy the default SCHEMA name rules (reserved words and name 
pattern).
+   * </ul>
+   *
+   * <p>For all other scopes, the default validation rules apply.
+   */
+  @Override
+  public CapabilityResult specificationOnName(Scope scope, String name) {
+    if (scope == Scope.SCHEMA && name.contains(schemaSeparator)) {
+      return validateSegments(name, schemaSeparator);
+    }
+    return Capability.super.specificationOnName(scope, name);
+  }
+
+  private CapabilityResult validateSegments(String name, String separator) {
+    String[] segments = HierarchicalSchemaUtil.splitSchemaName(name, 
separator);
+    for (String segment : segments) {
+      if (segment.isEmpty()) {
+        return CapabilityResult.unsupported(
+            String.format(
+                "The SCHEMA name '%s' contains an empty segment after 
splitting by '%s'.",
+                name, separator));
+      }
+      CapabilityResult segmentResult = 
Capability.super.specificationOnName(Scope.SCHEMA, segment);
+      if (!segmentResult.supported()) {
+        return CapabilityResult.unsupported(
+            String.format(
+                "The SCHEMA name '%s' contains an illegal segment '%s': %s",
+                name, segment, segmentResult.unsupportedMessage()));
+      }
+    }
+    return CapabilityResult.SUPPORTED;
+  }
 }
diff --git 
a/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalogOperations.java
 
b/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalogOperations.java
index a0e985ea49..0e7f8d48bd 100644
--- 
a/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalogOperations.java
+++ 
b/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalogOperations.java
@@ -57,6 +57,7 @@ import 
org.apache.gravitino.iceberg.common.authentication.SupportsKerberos;
 import org.apache.gravitino.iceberg.common.ops.IcebergCatalogWrapper;
 import 
org.apache.gravitino.iceberg.common.ops.IcebergCatalogWrapper.IcebergTableChange;
 import 
org.apache.gravitino.iceberg.common.ops.KerberosAwareIcebergCatalogProxy;
+import org.apache.gravitino.iceberg.common.utils.IcebergIdentifierUtils;
 import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.rel.Column;
 import org.apache.gravitino.rel.Representation;
@@ -72,6 +73,7 @@ import org.apache.gravitino.rel.expressions.sorts.SortOrder;
 import org.apache.gravitino.rel.expressions.transforms.Transform;
 import org.apache.gravitino.rel.indexes.Index;
 import org.apache.gravitino.utils.ClassLoaderResourceCleanerUtils;
+import org.apache.gravitino.utils.HierarchicalSchemaUtil;
 import org.apache.gravitino.utils.MapUtils;
 import org.apache.gravitino.utils.PrincipalUtils;
 import org.apache.iceberg.catalog.TableIdentifier;
@@ -159,13 +161,31 @@ public class IcebergCatalogOperations
   @Override
   public NameIdentifier[] listSchemas(Namespace namespace) throws 
NoSuchCatalogException {
     try {
+      String separator = HierarchicalSchemaUtil.schemaSeparator();
+      org.apache.iceberg.catalog.Namespace icebergParent;
+      if (namespace.length() == 2) {
+        icebergParent = org.apache.iceberg.catalog.Namespace.empty();
+      } else {
+        String parentPath =
+            String.join(separator, Arrays.copyOfRange(namespace.levels(), 2, 
namespace.length()));
+        icebergParent =
+            
IcebergIdentifierUtils.getIcebergNamespaceFromSchemaName(parentPath, separator);
+      }
+
       List<org.apache.iceberg.catalog.Namespace> namespaces =
-          icebergCatalogWrapper
-              .listNamespace(IcebergCatalogWrapperHelper.getIcebergNamespace())
-              .namespaces();
+          icebergCatalogWrapper.listNamespace(icebergParent).namespaces();
 
+      Namespace catalogNamespace = Namespace.of(namespace.level(0), 
namespace.level(1));
       return namespaces.stream()
-          .map(icebergNamespace -> NameIdentifier.of(namespace, 
icebergNamespace.toString()))
+          .map(
+              icebergNamespace -> {
+                // Convert the multi-level Iceberg namespace back to a logical 
schema name using the
+                // configured separator so upper layers receive consistent 
logical names.
+                String logicalName =
+                    IcebergIdentifierUtils.icebergNamespaceToSchemaName(
+                        icebergNamespace, separator);
+                return NameIdentifier.of(catalogNamespace, logicalName);
+              })
           .toArray(NameIdentifier[]::new);
     } catch (NoSuchNamespaceException e) {
       throw new NoSuchSchemaException(
diff --git 
a/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/TestIcebergCatalogCapability.java
 
b/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/TestIcebergCatalogCapability.java
new file mode 100644
index 0000000000..0172f2fb2a
--- /dev/null
+++ 
b/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/TestIcebergCatalogCapability.java
@@ -0,0 +1,106 @@
+/*
+ * 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.catalog.lakehouse.iceberg;
+
+import org.apache.gravitino.connector.capability.Capability;
+import org.apache.gravitino.connector.capability.CapabilityResult;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestIcebergCatalogCapability {
+
+  @Test
+  public void testSchemaNameSupportsConfiguredSeparator() {
+    IcebergCatalogCapability capability = new IcebergCatalogCapability(":");
+
+    CapabilityResult result = 
capability.specificationOnName(Capability.Scope.SCHEMA, "team:sales");
+    Assertions.assertTrue(result.supported());
+  }
+
+  @Test
+  public void testSchemaNameSupportsDeepNesting() {
+    IcebergCatalogCapability capability = new IcebergCatalogCapability(":");
+
+    CapabilityResult result =
+        capability.specificationOnName(Capability.Scope.SCHEMA, 
"team:sales:reports");
+    Assertions.assertTrue(result.supported());
+  }
+
+  @Test
+  public void testSchemaNameRejectsEmptySegments() {
+    IcebergCatalogCapability capability = new IcebergCatalogCapability(":");
+
+    CapabilityResult result =
+        capability.specificationOnName(Capability.Scope.SCHEMA, "team::sales");
+    Assertions.assertFalse(result.supported());
+  }
+
+  @Test
+  public void testSchemaNameRejectsLeadingSeparator() {
+    IcebergCatalogCapability capability = new IcebergCatalogCapability(":");
+
+    CapabilityResult result = 
capability.specificationOnName(Capability.Scope.SCHEMA, ":sales");
+    Assertions.assertFalse(result.supported());
+  }
+
+  @Test
+  public void testSchemaNameRejectsTrailingSeparator() {
+    IcebergCatalogCapability capability = new IcebergCatalogCapability(":");
+
+    CapabilityResult result = 
capability.specificationOnName(Capability.Scope.SCHEMA, "sales:");
+    Assertions.assertFalse(result.supported());
+  }
+
+  @Test
+  public void testFlatSchemaNameAllowed() {
+    IcebergCatalogCapability capability = new IcebergCatalogCapability(":");
+
+    CapabilityResult result = 
capability.specificationOnName(Capability.Scope.SCHEMA, "flat");
+    Assertions.assertTrue(result.supported());
+  }
+
+  @Test
+  public void testNonSchemaScopeStillUsesDefaultRules() {
+    IcebergCatalogCapability capability = new IcebergCatalogCapability(":");
+
+    CapabilityResult result = 
capability.specificationOnName(Capability.Scope.TABLE, "table:name");
+    Assertions.assertFalse(result.supported());
+  }
+
+  @Test
+  public void testDefaultConstructorUsesColonSeparator() {
+    IcebergCatalogCapability capability = new IcebergCatalogCapability(":");
+
+    CapabilityResult result = 
capability.specificationOnName(Capability.Scope.SCHEMA, "team:sales");
+    Assertions.assertTrue(result.supported());
+  }
+
+  @Test
+  public void testCustomSeparatorSlash() {
+    IcebergCatalogCapability capability = new IcebergCatalogCapability("/");
+
+    CapabilityResult validResult =
+        capability.specificationOnName(Capability.Scope.SCHEMA, "team/sales");
+    Assertions.assertTrue(validResult.supported());
+
+    CapabilityResult invalidResult =
+        capability.specificationOnName(Capability.Scope.SCHEMA, "team//sales");
+    Assertions.assertFalse(invalidResult.supported());
+  }
+}
diff --git 
a/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/TestIcebergCatalogOperations.java
 
b/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/TestIcebergCatalogOperations.java
index 06e95e4bd8..dfbd129a23 100644
--- 
a/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/TestIcebergCatalogOperations.java
+++ 
b/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/TestIcebergCatalogOperations.java
@@ -18,14 +18,26 @@
  */
 package org.apache.gravitino.catalog.lakehouse.iceberg;
 
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
 import com.google.common.collect.ImmutableMap;
+import java.util.Arrays;
 import org.apache.gravitino.Catalog;
 import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.Namespace;
 import org.apache.gravitino.exceptions.GravitinoRuntimeException;
+import org.apache.gravitino.iceberg.common.ops.IcebergCatalogWrapper;
+import org.apache.iceberg.rest.responses.ListNamespacesResponse;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
 public class TestIcebergCatalogOperations {
+
+  private static final String METALAKE = "metalake";
+  private static final String CATALOG = "catalog";
+
   @Test
   public void testTestConnection() {
     IcebergCatalogOperations catalogOperations = new 
IcebergCatalogOperations();
@@ -34,7 +46,7 @@ public class TestIcebergCatalogOperations {
             GravitinoRuntimeException.class,
             () ->
                 catalogOperations.testConnection(
-                    NameIdentifier.of("metalake", "catalog"),
+                    NameIdentifier.of(METALAKE, CATALOG),
                     Catalog.Type.RELATIONAL,
                     "iceberg",
                     "comment",
@@ -42,4 +54,45 @@ public class TestIcebergCatalogOperations {
     Assertions.assertTrue(
         exception.getMessage().contains("Failed to run listNamespace on 
Iceberg catalog"));
   }
+
+  @Test
+  public void testListSchemasConvertsMultiLevelNamespacesToLogicalNames() {
+    IcebergCatalogWrapper mockWrapper = mock(IcebergCatalogWrapper.class);
+    IcebergCatalogOperations catalogOperations = new 
IcebergCatalogOperations();
+    catalogOperations.icebergCatalogWrapper = mockWrapper;
+
+    org.apache.iceberg.catalog.Namespace flatNs = 
org.apache.iceberg.catalog.Namespace.of("mydb");
+    org.apache.iceberg.catalog.Namespace hierarchicalNs =
+        org.apache.iceberg.catalog.Namespace.of("A", "B", "C");
+    ListNamespacesResponse mockResponse =
+        ListNamespacesResponse.builder().addAll(Arrays.asList(flatNs, 
hierarchicalNs)).build();
+    when(mockWrapper.listNamespace(any())).thenReturn(mockResponse);
+
+    NameIdentifier[] result = 
catalogOperations.listSchemas(Namespace.of(METALAKE, CATALOG));
+
+    Assertions.assertEquals(2, result.length);
+    // Flat namespace stays as-is.
+    Assertions.assertTrue(Arrays.stream(result).anyMatch(id -> 
"mydb".equals(id.name())));
+    // Multi-level Iceberg namespace is joined with the configured separator.
+    Assertions.assertTrue(Arrays.stream(result).anyMatch(id -> 
"A:B:C".equals(id.name())));
+  }
+
+  @Test
+  public void testListSchemasFlatOnlyReturnsUnchangedNames() {
+    IcebergCatalogWrapper mockWrapper = mock(IcebergCatalogWrapper.class);
+    IcebergCatalogOperations catalogOperations = new 
IcebergCatalogOperations();
+    catalogOperations.icebergCatalogWrapper = mockWrapper;
+
+    org.apache.iceberg.catalog.Namespace ns1 = 
org.apache.iceberg.catalog.Namespace.of("db1");
+    org.apache.iceberg.catalog.Namespace ns2 = 
org.apache.iceberg.catalog.Namespace.of("db2");
+    ListNamespacesResponse mockResponse =
+        ListNamespacesResponse.builder().addAll(Arrays.asList(ns1, 
ns2)).build();
+    when(mockWrapper.listNamespace(any())).thenReturn(mockResponse);
+
+    NameIdentifier[] result = 
catalogOperations.listSchemas(Namespace.of(METALAKE, CATALOG));
+
+    Assertions.assertEquals(2, result.length);
+    Assertions.assertTrue(Arrays.stream(result).anyMatch(id -> 
"db1".equals(id.name())));
+    Assertions.assertTrue(Arrays.stream(result).anyMatch(id -> 
"db2".equals(id.name())));
+  }
 }
diff --git a/clients/client-java/build.gradle.kts 
b/clients/client-java/build.gradle.kts
index 11929c8fec..b3850d71fe 100644
--- a/clients/client-java/build.gradle.kts
+++ b/clients/client-java/build.gradle.kts
@@ -59,6 +59,7 @@ dependencies {
   testImplementation(libs.mockito.core)
   testImplementation(libs.mockserver.netty)
   testImplementation(libs.mockserver.client.java)
+  testImplementation(libs.h2db)
   testImplementation(libs.mysql.driver)
   testImplementation(libs.postgresql.driver)
   testImplementation(libs.testcontainers)
@@ -85,6 +86,10 @@ tasks.test {
     dependsOn(":catalogs:catalog-model:jar", 
":catalogs:catalog-model:runtimeJars")
     dependsOn(":catalogs:catalog-hive:jar", 
":catalogs:catalog-hive:runtimeJars")
     dependsOn(":catalogs:catalog-kafka:jar", 
":catalogs:catalog-kafka:runtimeJars")
+    dependsOn(
+      ":catalogs:catalog-lakehouse-iceberg:jar",
+      ":catalogs:catalog-lakehouse-iceberg:runtimeJars"
+    )
   }
 }
 
diff --git 
a/clients/client-java/src/main/java/org/apache/gravitino/client/BaseSchemaCatalog.java
 
b/clients/client-java/src/main/java/org/apache/gravitino/client/BaseSchemaCatalog.java
index 45ae377ca2..0553abfc4b 100644
--- 
a/clients/client-java/src/main/java/org/apache/gravitino/client/BaseSchemaCatalog.java
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/BaseSchemaCatalog.java
@@ -18,11 +18,13 @@
  */
 package org.apache.gravitino.client;
 
+import com.google.common.base.Preconditions;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.Catalog;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.MetadataObjects;
@@ -143,16 +145,25 @@ abstract class BaseSchemaCatalog extends CatalogDTO
    */
   @Override
   public String[] listSchemas() throws NoSuchCatalogException {
+    return doListSchemas(Collections.emptyMap());
+  }
 
-    EntityListResponse resp =
-        restClient.get(
-            formatSchemaRequestPath(schemaNamespace()),
-            EntityListResponse.class,
-            Collections.emptyMap(),
-            ErrorHandlers.schemaErrorHandler());
-    resp.validate();
-
-    return 
Arrays.stream(resp.identifiers()).map(NameIdentifier::name).toArray(String[]::new);
+  /**
+   * List the schemas directly under the given parent schema.
+   *
+   * @param parentSchema The parent (possibly hierarchical) schema name whose 
direct children are
+   *     listed, e.g. {@code "a"} or {@code "a:b"}. Must not be null or blank.
+   * @return A list of schema names directly under the given parent schema.
+   * @throws IllegalArgumentException if {@code parentSchema} is null or blank.
+   * @throws NoSuchCatalogException if the catalog with specified namespace 
does not exist.
+   * @throws NoSuchSchemaException if the parent schema does not exist.
+   */
+  @Override
+  public String[] listSchemas(String parentSchema)
+      throws NoSuchCatalogException, NoSuchSchemaException {
+    Preconditions.checkArgument(
+        StringUtils.isNotBlank(parentSchema), "parentSchema must not be null 
or blank");
+    return doListSchemas(Collections.singletonMap("parentSchema", 
parentSchema));
   }
 
   /**
@@ -371,4 +382,17 @@ abstract class BaseSchemaCatalog extends CatalogDTO
   public boolean dropFunction(NameIdentifier ident) {
     return functionOperations.dropFunction(ident);
   }
+
+  private String[] doListSchemas(Map<String, String> queryParams) {
+    EntityListResponse resp =
+        restClient.get(
+            formatSchemaRequestPath(schemaNamespace()),
+            queryParams,
+            EntityListResponse.class,
+            Collections.emptyMap(),
+            ErrorHandlers.schemaErrorHandler());
+    resp.validate();
+
+    return 
Arrays.stream(resp.identifiers()).map(NameIdentifier::name).toArray(String[]::new);
+  }
 }
diff --git 
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestRelationalCatalog.java
 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestRelationalCatalog.java
index ff0a29c032..b10e9f981e 100644
--- 
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestRelationalCatalog.java
+++ 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestRelationalCatalog.java
@@ -190,6 +190,43 @@ public class TestRelationalCatalog extends TestBase {
     Assertions.assertTrue(ex2.getMessage().contains("unparsed error"));
   }
 
+  @Test
+  public void testListSchemasUnderParent() throws JsonProcessingException {
+    Namespace schemaNs = Namespace.of(metalakeName, catalogName);
+    String schemaPath = 
withSlash(RelationalCatalog.formatSchemaRequestPath(schemaNs));
+
+    // A non-blank parentSchema must be forwarded as the "parentSchema" query 
parameter.
+    NameIdentifier child = NameIdentifier.of(schemaNs, "a:b:c");
+    EntityListResponse resp = new EntityListResponse(new NameIdentifier[] 
{child});
+    buildMockResource(
+        Method.GET, schemaPath, ImmutableMap.of("parentSchema", "a:b"), null, 
resp, SC_OK);
+    String[] schemas = catalog.asSchemas().listSchemas("a:b");
+    Assertions.assertEquals(1, schemas.length);
+    Assertions.assertEquals("a:b:c", schemas[0]);
+
+    // A null or blank parentSchema is rejected before any request is sent.
+    SupportsSchemas schemas2 = catalog.asSchemas();
+    Assertions.assertThrows(IllegalArgumentException.class, () -> 
schemas2.listSchemas(null));
+    Assertions.assertThrows(IllegalArgumentException.class, () -> 
schemas2.listSchemas(""));
+    Assertions.assertThrows(IllegalArgumentException.class, () -> 
schemas2.listSchemas("  "));
+
+    // Test throw NoSuchSchemaException when the parent schema does not exist.
+    ErrorResponse errorResp =
+        ErrorResponse.notFound(NoSuchSchemaException.class.getSimpleName(), 
"schema not found");
+    buildMockResource(
+        Method.GET,
+        schemaPath,
+        ImmutableMap.of("parentSchema", "missing"),
+        null,
+        errorResp,
+        SC_NOT_FOUND);
+    SupportsSchemas supportsSchemas = catalog.asSchemas();
+    Throwable ex =
+        Assertions.assertThrows(
+            NoSuchSchemaException.class, () -> 
supportsSchemas.listSchemas("missing"));
+    Assertions.assertTrue(ex.getMessage().contains("schema not found"));
+  }
+
   @Test
   public void testCreateSchema() throws JsonProcessingException {
     String schemaName = "schema1";
diff --git 
a/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/HierarchicalSchemaAuthorizationIT.java
 
b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/HierarchicalSchemaAuthorizationIT.java
new file mode 100644
index 0000000000..e4c1142a39
--- /dev/null
+++ 
b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/HierarchicalSchemaAuthorizationIT.java
@@ -0,0 +1,330 @@
+/*
+ * 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 static org.junit.Assert.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.gravitino.Catalog;
+import org.apache.gravitino.Configs;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.MetadataObjects;
+import org.apache.gravitino.Schema;
+import org.apache.gravitino.authorization.Owner;
+import org.apache.gravitino.authorization.Privilege;
+import org.apache.gravitino.authorization.Privileges;
+import org.apache.gravitino.authorization.SecurableObject;
+import org.apache.gravitino.authorization.SecurableObjects;
+import org.apache.gravitino.client.GravitinoMetalake;
+import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.gravitino.exceptions.NoSuchSchemaException;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+
+/**
+ * Integration tests for hierarchical schema authorization.
+ *
+ * <p>Tests cover:
+ *
+ * <ol>
+ *   <li>Admin can create hierarchical schemas; auto-creates parent chain.
+ *   <li>Normal user cannot create hierarchical schemas without grants.
+ *   <li>Granting {@code create_schema} on a parent schema allows creating 
direct children.
+ *   <li>Granting {@code create_schema} on an ancestor schema inherits down to 
all descendants.
+ *   <li>List schemas returns only top-level schemas by default.
+ *   <li>{@code use_schema} on a hierarchical schema allows loading and 
listing it.
+ *   <li>Drop hierarchical schema requires ownership or catalog ownership.
+ * </ol>
+ */
+@Tag("gravitino-docker-test")
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+public class HierarchicalSchemaAuthorizationIT extends 
BaseRestApiAuthorizationIT {
+
+  private static final String CATALOG = "hierarchical_catalog";
+  private static final String ROLE = "hierarchical_role";
+
+  /** Top-level schemas created by admin at setup. */
+  private static final String ROOT_A = "A";
+
+  private static final String SCHEMA_AB = "A:B";
+  private static final String SCHEMA_ABC = "A:B:C";
+  private static final String SCHEMA_ABD = "A:B:D";
+
+  @BeforeAll
+  @Override
+  public void startIntegrationTest() throws Exception {
+    // Enable authorization and configure the hierarchical schema separator.
+    customConfigs.put(Configs.SCHEMA_SEPARATOR.getKey(), ":");
+    super.startIntegrationTest();
+
+    // Create an Iceberg catalog because ':' hierarchical schema names are 
only supported there.
+    // The Iceberg JDBC catalog is backed by an in-memory H2 database purely 
for test convenience:
+    // it is lightweight and needs no extra Docker container, so the IT can 
exercise the
+    // hierarchical-namespace behavior without a heavyweight metastore. H2 is 
NOT a supported
+    // production Iceberg backend.
+    Map<String, String> catalogProperties = new HashMap<>();
+    catalogProperties.put("catalog-backend", "jdbc");
+    catalogProperties.put("warehouse", 
"/tmp/gravitino-it-hierarchical-schema");
+    catalogProperties.put(
+        "uri", 
"jdbc:h2:mem:gravitino-it-hierarchical-schema;DB_CLOSE_DELAY=-1;MODE=MYSQL");
+    catalogProperties.put("jdbc-driver", "org.h2.Driver");
+    catalogProperties.put("jdbc-initialize", "true");
+    client
+        .loadMetalake(METALAKE)
+        .createCatalog(
+            CATALOG, Catalog.Type.RELATIONAL, "lakehouse-iceberg", "comment", 
catalogProperties);
+
+    // Grant the normal user a role with USE_CATALOG so it can interact with 
the catalog.
+    GravitinoMetalake metalake = client.loadMetalake(METALAKE);
+    List<SecurableObject> securableObjects = new ArrayList<>();
+    List<Privilege> privileges = new ArrayList<>();
+    privileges.add(Privileges.UseCatalog.allow());
+    securableObjects.add(SecurableObjects.ofCatalog(CATALOG, privileges));
+    metalake.createRole(ROLE, new HashMap<>(), securableObjects);
+    metalake.grantRolesToUser(ImmutableList.of(ROLE), NORMAL_USER);
+  }
+
+  /**
+   * Admin creates a hierarchical schema {@code A:B:C}. The server 
automatically ensures the parent
+   * chain {@code A} and {@code A:B} exists. Verify all three schemas are 
accessible by the admin.
+   */
+  @Test
+  @Order(1)
+  public void testAdminCreatesHierarchicalSchemaAutoCreatesParentChain() {
+    Catalog catalog = client.loadMetalake(METALAKE).loadCatalog(CATALOG);
+
+    // Creating "A:B:C" should auto-create "A" and "A:B".
+    catalog.asSchemas().createSchema(SCHEMA_ABC, "hierarchical schema", new 
HashMap<>());
+
+    // Default listing only returns top-level schemas.
+    String[] schemas = catalog.asSchemas().listSchemas();
+    List<String> schemaList = Arrays.asList(schemas);
+
+    assertTrue(schemaList.contains(ROOT_A), "Parent 'A' should be 
auto-created");
+    assertFalse(
+        schemaList.contains(SCHEMA_AB),
+        "Default listSchemas() should not include hierarchical schema A:B");
+    assertFalse(
+        schemaList.contains(SCHEMA_ABC),
+        "Default listSchemas() should not include hierarchical schema A:B:C");
+
+    // Verify hierarchical schemas exist via direct load.
+    assertEquals(SCHEMA_AB, catalog.asSchemas().loadSchema(SCHEMA_AB).name());
+    assertEquals(SCHEMA_ABC, 
catalog.asSchemas().loadSchema(SCHEMA_ABC).name());
+  }
+
+  /**
+   * Normal user without {@code create_schema} on the parent schema cannot 
create a hierarchical
+   * schema.
+   */
+  @Test
+  @Order(2)
+  public void testNormalUserCannotCreateHierarchicalSchemaWithoutGrant() {
+    Catalog catalogByNormalUser = 
normalUserClient.loadMetalake(METALAKE).loadCatalog(CATALOG);
+
+    // Creating "A:B:D" requires create_schema on parent "A:B" (or any 
ancestor / catalog).
+    assertThrows(
+        ForbiddenException.class,
+        () -> catalogByNormalUser.asSchemas().createSchema(SCHEMA_ABD, "test", 
new HashMap<>()));
+  }
+
+  /**
+   * Granting {@code create_schema} on the parent schema {@code A:B} allows 
the normal user to
+   * create a direct child {@code A:B:D}.
+   */
+  @Test
+  @Order(3)
+  public void testCreateSchemaWithGrantOnParentSchema() {
+    GravitinoMetalake metalake = client.loadMetalake(METALAKE);
+
+    // Grant create_schema on parent "A:B".
+    metalake.grantPrivilegesToRole(
+        ROLE,
+        MetadataObjects.of(CATALOG, SCHEMA_AB, MetadataObject.Type.SCHEMA),
+        ImmutableList.of(Privileges.UseSchema.allow(), 
Privileges.CreateSchema.allow()));
+
+    Catalog catalogByNormalUser = 
normalUserClient.loadMetalake(METALAKE).loadCatalog(CATALOG);
+    // Normal user can now create a child of "A:B".
+    catalogByNormalUser.asSchemas().createSchema(SCHEMA_ABD, "child of A:B", 
new HashMap<>());
+
+    // Verify "A:B:D" was created.
+    Catalog catalog = client.loadMetalake(METALAKE).loadCatalog(CATALOG);
+    assertEquals(SCHEMA_ABD, 
catalog.asSchemas().loadSchema(SCHEMA_ABD).name());
+  }
+
+  /**
+   * Privilege inheritance: granting {@code create_schema} on ancestor {@code 
A} allows creating
+   * schemas anywhere in the {@code A:*} subtree via the inheritance chain.
+   */
+  @Test
+  @Order(4)
+  public void testCreateSchemaInheritedFromAncestorGrant() {
+    GravitinoMetalake metalake = client.loadMetalake(METALAKE);
+
+    // Grant create_schema on ancestor "A". Due to inheritance, this covers 
"A:B:*" as well.
+    metalake.grantPrivilegesToRole(
+        ROLE,
+        MetadataObjects.of(CATALOG, ROOT_A, MetadataObject.Type.SCHEMA),
+        ImmutableList.of(Privileges.UseSchema.allow(), 
Privileges.CreateSchema.allow()));
+
+    Catalog catalogByNormalUser = 
normalUserClient.loadMetalake(METALAKE).loadCatalog(CATALOG);
+
+    // Normal user can now create "A:B:E" via inheritance from "A".
+    catalogByNormalUser.asSchemas().createSchema("A:B:E", "inherited from A", 
new HashMap<>());
+
+    // Verify "A:B:E" was created.
+    Catalog catalog = client.loadMetalake(METALAKE).loadCatalog(CATALOG);
+    assertEquals("A:B:E", catalog.asSchemas().loadSchema("A:B:E").name());
+  }
+
+  /**
+   * By default {@code listSchemas()} returns only top-level schemas (those 
without the separator).
+   * Hierarchical schemas are accessible through the parent-aware filter.
+   */
+  @Test
+  @Order(5)
+  public void testListSchemasReturnsTopLevelByDefault() {
+    Catalog catalog = client.loadMetalake(METALAKE).loadCatalog(CATALOG);
+    String[] schemas = catalog.asSchemas().listSchemas();
+
+    // Top-level schemas should include "A" (and any others created by prior 
tests).
+    List<String> schemaList = Arrays.asList(schemas);
+    assertTrue(schemaList.contains(ROOT_A));
+
+    // Hierarchical schemas should NOT appear in the default listing.
+    schemaList.forEach(
+        name ->
+            assertFalse(
+                name.contains(":"),
+                "Default listSchemas() should return only top-level schemas, 
got: " + name));
+  }
+
+  /**
+   * Granting {@code use_schema} on a hierarchical schema allows the normal 
user to load it, even
+   * without grants on intermediate parent schemas.
+   */
+  @Test
+  @Order(6)
+  public void testUseSchemaPrivilegeOnHierarchicalSchema() {
+    GravitinoMetalake metalake = client.loadMetalake(METALAKE);
+
+    // Grant use_schema specifically on "A:B:C".
+    metalake.grantPrivilegesToRole(
+        ROLE,
+        MetadataObjects.of(CATALOG, SCHEMA_ABC, MetadataObject.Type.SCHEMA),
+        ImmutableList.of(Privileges.UseSchema.allow()));
+
+    Catalog catalogByNormalUser = 
normalUserClient.loadMetalake(METALAKE).loadCatalog(CATALOG);
+
+    // Normal user can now load "A:B:C".
+    Schema loaded = catalogByNormalUser.asSchemas().loadSchema(SCHEMA_ABC);
+    assertEquals(SCHEMA_ABC, loaded.name());
+  }
+
+  /**
+   * Dropping a hierarchical schema requires ownership or catalog ownership. 
Normal user without
+   * ownership cannot drop; after becoming owner, they can.
+   */
+  @Test
+  @Order(7)
+  public void testDropHierarchicalSchemaRequiresOwnership() {
+    GravitinoMetalake metalake = client.loadMetalake(METALAKE);
+    Catalog catalogByNormalUser = 
normalUserClient.loadMetalake(METALAKE).loadCatalog(CATALOG);
+
+    // Normal user cannot drop "A:B:C" without ownership.
+    assertThrows(
+        ForbiddenException.class,
+        () -> catalogByNormalUser.asSchemas().dropSchema(SCHEMA_ABC, false));
+
+    // Set normal user as owner of "A:B:C".
+    metalake.setOwner(
+        MetadataObjects.of(CATALOG, SCHEMA_ABC, MetadataObject.Type.SCHEMA),
+        NORMAL_USER,
+        Owner.Type.USER);
+
+    // Normal user can now drop it.
+    catalogByNormalUser.asSchemas().dropSchema(SCHEMA_ABC, false);
+
+    // Verify "A:B:C" is gone; parent "A:B" should still exist.
+    Catalog catalog = client.loadMetalake(METALAKE).loadCatalog(CATALOG);
+    assertThrows(NoSuchSchemaException.class, () -> 
catalog.asSchemas().loadSchema(SCHEMA_ABC));
+    assertEquals(ROOT_A, catalog.asSchemas().loadSchema(ROOT_A).name());
+  }
+
+  /**
+   * Tests that {@code create_schema} cannot be bound to a SCHEMA-level 
securable object before the
+   * canBindTo change (this is now allowed). Grants {@code 
CreateSchema.allow()} on a schema object
+   * and verifies no exception is thrown.
+   */
+  @Test
+  @Order(8)
+  public void testCreateSchemaPrivilegeCanBindToSchema() {
+    GravitinoMetalake metalake = client.loadMetalake(METALAKE);
+
+    // This should NOT throw — CreateSchema.canBindTo(SCHEMA) is now true.
+    metalake.grantPrivilegesToRole(
+        ROLE,
+        MetadataObjects.of(CATALOG, ROOT_A, MetadataObject.Type.SCHEMA),
+        ImmutableList.of(Privileges.CreateSchema.allow()));
+
+    // Verify the grant was applied by checking normal user can create under 
"A".
+    Catalog catalogByNormalUser = 
normalUserClient.loadMetalake(METALAKE).loadCatalog(CATALOG);
+    catalogByNormalUser.asSchemas().createSchema("A:F", "via schema-level 
grant", new HashMap<>());
+
+    Catalog catalog = client.loadMetalake(METALAKE).loadCatalog(CATALOG);
+    assertEquals("A:F", catalog.asSchemas().loadSchema("A:F").name());
+  }
+
+  /**
+   * Listing with an explicit {@code parentSchema} returns the direct children 
of that parent,
+   * exposing the hierarchical schemas that the default top-level listing 
hides. Uses a
+   * self-contained {@code P:Q} subtree so the assertions do not depend on 
schemas created or
+   * dropped by earlier ordered tests.
+   */
+  @Test
+  @Order(9)
+  public void testListSchemasUnderParentReturnsChildren() {
+    Catalog catalog = client.loadMetalake(METALAKE).loadCatalog(CATALOG);
+
+    catalog.asSchemas().createSchema("P:Q:R", "child R", new HashMap<>());
+    catalog.asSchemas().createSchema("P:Q:S", "child S", new HashMap<>());
+
+    // Children are not visible in the default top-level listing.
+    List<String> topLevel = Arrays.asList(catalog.asSchemas().listSchemas());
+    assertTrue(topLevel.contains("P"), "Auto-created parent 'P' should be a 
top-level schema");
+    assertFalse(topLevel.contains("P:Q"), "Default listSchemas() should not 
include 'P:Q'");
+    assertFalse(topLevel.contains("P:Q:R"), "Default listSchemas() should not 
include 'P:Q:R'");
+
+    // Listing under "P:Q" returns its direct children as logical hierarchical 
names.
+    List<String> children = 
Arrays.asList(catalog.asSchemas().listSchemas("P:Q"));
+    assertTrue(children.contains("P:Q:R"), "listSchemas(\"P:Q\") should 
include P:Q:R");
+    assertTrue(children.contains("P:Q:S"), "listSchemas(\"P:Q\") should 
include P:Q:S");
+  }
+}
diff --git a/clients/client-python/gravitino/api/supports_schemas.py 
b/clients/client-python/gravitino/api/supports_schemas.py
index 36f0ac6a98..7a61400b6f 100644
--- a/clients/client-python/gravitino/api/supports_schemas.py
+++ b/clients/client-python/gravitino/api/supports_schemas.py
@@ -16,7 +16,7 @@
 # under the License.
 
 from abc import ABC, abstractmethod
-from typing import Dict, List
+from typing import Dict, List, Optional
 
 from gravitino.api.schema import Schema
 from gravitino.api.schema_change import SchemaChange
@@ -30,15 +30,29 @@ class SupportsSchemas(ABC):
     """
 
     @abstractmethod
-    def list_schemas(self) -> List[str]:
+    def list_schemas(self, parent_schema: Optional[str] = None) -> List[str]:
         """List schemas under the entity.
 
         If an entity such as a table, view exists, its parent schemas must 
also exist and must be
         returned by this discovery method. For example, if table a.b.t exists, 
this method invoked as
         listSchemas(a) must return [b] in the result array
 
+        If ``parent_schema`` is provided, list the schemas directly under the 
given parent schema.
+        This is only meaningful for catalogs that support hierarchical 
(multi-level) schemas, such
+        as an Iceberg catalog accessed through the Gravitino REST server with 
a configured schema
+        separator. For example, when the schemas ``a``, ``a:b`` and ``a:b:c`` 
exist, this method
+        invoked with parent ``a:b`` returns ``[a:b:c]``. For a flat catalog, 
or a parent schema that
+        has no children, an empty list is returned.
+
+        Args:
+            parent_schema: The parent (possibly hierarchical) schema name 
whose direct children are
+                listed, e.g. ``"a"`` or ``"a:b"``. When ``None``, all schemas 
under the catalog are
+                listed. Must not be blank when provided.
+
         Raises:
+            IllegalArgumentException: If ``parent_schema`` is provided but 
blank.
             NoSuchCatalogException: If the catalog does not exist.
+            NoSuchSchemaException: If ``parent_schema`` is provided but does 
not exist.
 
         Returns:
             A list of schema names under the namespace.
diff --git a/clients/client-python/gravitino/client/base_schema_catalog.py 
b/clients/client-python/gravitino/client/base_schema_catalog.py
index 89872f893a..a2cbae094b 100644
--- a/clients/client-python/gravitino/client/base_schema_catalog.py
+++ b/clients/client-python/gravitino/client/base_schema_catalog.py
@@ -119,17 +119,32 @@ class BaseSchemaCatalog(
     def as_function_catalog(self):
         return self
 
-    def list_schemas(self) -> List[str]:
-        """List all the schemas under the given catalog namespace.
+    def list_schemas(self, parent_schema: Optional[str] = None) -> List[str]:
+        """List the schemas under the given catalog namespace, or directly 
under the given parent
+        schema when ``parent_schema`` is provided.
+
+        Args:
+            parent_schema: The parent (possibly hierarchical) schema name 
whose direct children are
+                listed, e.g. ``"a"`` or ``"a:b"``. When ``None``, all schemas 
under the catalog are
+                listed. Must not be blank when provided.
 
         Raises:
+            IllegalArgumentException if ``parent_schema`` is provided but 
blank.
             NoSuchCatalogException if the catalog with specified namespace 
does not exist.
+            NoSuchSchemaException if ``parent_schema`` is provided but does 
not exist.
 
         Returns:
-             A list of schema names under the given catalog namespace.
+             A list of schema names under the given catalog namespace or 
parent schema.
         """
+        params = None
+        if parent_schema is not None:
+            if not parent_schema.strip():
+                raise IllegalArgumentException("parentSchema must not be null 
or blank")
+            params = {"parentSchema": parent_schema}
+
         resp = self.rest_client.get(
             
BaseSchemaCatalog.format_schema_request_path(self._schema_namespace()),
+            params=params,
             error_handler=SCHEMA_ERROR_HANDLER,
         )
         entity_list_response = EntityListResponse.from_json(
diff --git a/clients/client-python/tests/unittests/test_base_schema_catalog.py 
b/clients/client-python/tests/unittests/test_base_schema_catalog.py
new file mode 100644
index 0000000000..907bb4ab57
--- /dev/null
+++ b/clients/client-python/tests/unittests/test_base_schema_catalog.py
@@ -0,0 +1,100 @@
+# 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.
+
+import unittest
+from unittest.mock import patch, Mock
+
+from gravitino.client.relational_catalog import RelationalCatalog
+from gravitino.dto.audit_dto import AuditDTO
+from gravitino.dto.responses.entity_list_response import EntityListResponse
+from gravitino.exceptions.base import (
+    IllegalArgumentException,
+    NoSuchSchemaException,
+)
+from gravitino.name_identifier import NameIdentifier
+from gravitino.namespace import Namespace
+from gravitino.utils import HTTPClient, Response
+
+
+class TestBaseSchemaCatalog(unittest.TestCase):
+    metalake_name = "test_metalake"
+    catalog_name = "test_catalog"
+    catalog_namespace = Namespace.of(metalake_name)
+
+    @classmethod
+    def setUpClass(cls) -> None:
+        cls.rest_client = HTTPClient("http://localhost:8090";)
+        cls.catalog = RelationalCatalog(
+            catalog_namespace=cls.catalog_namespace,
+            name=cls.catalog_name,
+            catalog_type=RelationalCatalog.Type.RELATIONAL,
+            provider="test_provider",
+            audit=AuditDTO("anonymous"),
+            rest_client=cls.rest_client,
+        )
+
+    def _get_mock_http_resp(self, json_str: str, return_code: int = 200):
+        mock_http_resp = Mock()
+        mock_http_resp.getcode.return_value = return_code
+        mock_http_resp.read.return_value = json_str.encode("utf-8")
+        mock_http_resp.info.return_value = None
+        mock_http_resp.url = None
+        return Response(mock_http_resp)
+
+    def test_list_schemas(self):
+        schema_a = NameIdentifier.of(self.metalake_name, self.catalog_name, 
"a")
+        schema_b = NameIdentifier.of(self.metalake_name, self.catalog_name, 
"b")
+
+        resp_body = EntityListResponse(_code=0, _idents=[schema_a, schema_b])
+        mock_resp = self._get_mock_http_resp(resp_body.to_json())
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get", 
return_value=mock_resp
+        ) as mock_get:
+            schemas = self.catalog.as_schemas().list_schemas()
+            self.assertEqual(["a", "b"], schemas)
+            # No parentSchema query param when listing all schemas.
+            self.assertIsNone(mock_get.call_args.kwargs["params"])
+
+    def test_list_schemas_under_parent(self):
+        schema_abc = NameIdentifier.of(self.metalake_name, self.catalog_name, 
"a:b:c")
+
+        resp_body = EntityListResponse(_code=0, _idents=[schema_abc])
+        mock_resp = self._get_mock_http_resp(resp_body.to_json())
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get", 
return_value=mock_resp
+        ) as mock_get:
+            schemas = self.catalog.as_schemas().list_schemas("a:b")
+            self.assertEqual(["a:b:c"], schemas)
+            # The parent schema is passed through as a query param.
+            self.assertEqual(
+                {"parentSchema": "a:b"}, mock_get.call_args.kwargs["params"]
+            )
+
+    def test_list_schemas_with_blank_parent(self):
+        for blank in ["", "   "]:
+            with self.assertRaises(IllegalArgumentException):
+                self.catalog.as_schemas().list_schemas(blank)
+
+    def test_list_schemas_under_missing_parent(self):
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get",
+            side_effect=NoSuchSchemaException("schema not found"),
+        ):
+            with self.assertRaises(NoSuchSchemaException):
+                self.catalog.as_schemas().list_schemas("a:b")
diff --git 
a/core/src/main/java/org/apache/gravitino/catalog/SchemaOperationDispatcher.java
 
b/core/src/main/java/org/apache/gravitino/catalog/SchemaOperationDispatcher.java
index b61c99e0d7..3d282683d5 100644
--- 
a/core/src/main/java/org/apache/gravitino/catalog/SchemaOperationDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/catalog/SchemaOperationDispatcher.java
@@ -23,6 +23,7 @@ import static 
org.apache.gravitino.catalog.PropertiesMetadataHelpers.validatePro
 import static 
org.apache.gravitino.utils.NameIdentifierUtil.getCatalogIdentifier;
 
 import java.time.Instant;
+import java.util.List;
 import java.util.Map;
 import org.apache.gravitino.EntityAlreadyExistsException;
 import org.apache.gravitino.EntityStore;
@@ -43,6 +44,8 @@ import org.apache.gravitino.lock.TreeLockUtils;
 import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.SchemaEntity;
 import org.apache.gravitino.storage.IdGenerator;
+import org.apache.gravitino.utils.HierarchicalSchemaUtil;
+import org.apache.gravitino.utils.NameIdentifierUtil;
 import org.apache.gravitino.utils.PrincipalUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -339,10 +342,48 @@ public class SchemaOperationDispatcher extends 
OperationDispatcher implements Sc
           } catch (Exception e) {
             throw new RuntimeException(e);
           }
+
+          cleanupOrphanedAncestors(catalogIdent, ident);
           return droppedFromCatalog;
         });
   }
 
+  /**
+   * Reconciles auto-created ancestor entities after a hierarchical schema 
leaf is dropped. This
+   * mirrors {@code IcebergNamespaceHookDispatcher.dropNamespace}: walk the 
ancestors
+   * innermost-to-outermost and delete the orphaned Gravitino entity for each 
ancestor that no
+   * longer exists in the catalog, stopping at the first ancestor that still 
exists (its existence
+   * implies all of its outer ancestors exist). For a flat schema name this is 
a no-op.
+   *
+   * @param catalogIdent the identifier of the catalog the schema belongs to
+   * @param ident the identifier of the dropped (leaf) schema
+   */
+  private void cleanupOrphanedAncestors(NameIdentifier catalogIdent, 
NameIdentifier ident) {
+    String separator = HierarchicalSchemaUtil.schemaSeparator();
+    List<String> ancestorNames = 
HierarchicalSchemaUtil.getAncestorNames(ident.name(), separator);
+    String metalake = ident.namespace().level(0);
+    String catalog = ident.namespace().level(1);
+    for (int i = ancestorNames.size() - 1; i >= 0; i--) {
+      NameIdentifier ancestorIdent =
+          NameIdentifierUtil.ofSchema(metalake, catalog, ancestorNames.get(i));
+      boolean ancestorExistsInCatalog =
+          doWithCatalog(
+              catalogIdent,
+              c -> c.doWithSchemaOps(s -> s.schemaExists(ancestorIdent)),
+              RuntimeException.class);
+      if (ancestorExistsInCatalog) {
+        break;
+      }
+      try {
+        store.delete(ancestorIdent, SCHEMA, true);
+      } catch (NoSuchEntityException e) {
+        LOG.warn("The orphaned ancestor schema does not exist in the store: 
{}", ancestorIdent, e);
+      } catch (Exception e) {
+        throw new RuntimeException(e);
+      }
+    }
+  }
+
   private void importSchema(NameIdentifier identifier) {
     EntityCombinedSchema schema = internalLoadSchema(identifier);
     if (schema.imported()) {
diff --git 
a/core/src/main/java/org/apache/gravitino/hook/SchemaHookDispatcher.java 
b/core/src/main/java/org/apache/gravitino/hook/SchemaHookDispatcher.java
index 6114f7d2fb..dae07ffbc6 100644
--- a/core/src/main/java/org/apache/gravitino/hook/SchemaHookDispatcher.java
+++ b/core/src/main/java/org/apache/gravitino/hook/SchemaHookDispatcher.java
@@ -18,10 +18,13 @@
  */
 package org.apache.gravitino.hook;
 
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.Schema;
@@ -36,6 +39,9 @@ import org.apache.gravitino.exceptions.NoSuchCatalogException;
 import org.apache.gravitino.exceptions.NoSuchSchemaException;
 import org.apache.gravitino.exceptions.NonEmptySchemaException;
 import org.apache.gravitino.exceptions.SchemaAlreadyExistsException;
+import org.apache.gravitino.lock.LockType;
+import org.apache.gravitino.lock.TreeLockUtils;
+import org.apache.gravitino.utils.HierarchicalSchemaUtil;
 import org.apache.gravitino.utils.NameIdentifierUtil;
 import org.apache.gravitino.utils.PrincipalUtils;
 
@@ -59,24 +65,94 @@ public class SchemaHookDispatcher implements 
SchemaDispatcher {
   @Override
   public Schema createSchema(NameIdentifier ident, String comment, Map<String, 
String> properties)
       throws NoSuchCatalogException, SchemaAlreadyExistsException {
-    Schema schema = dispatcher.createSchema(ident, comment, properties);
+    // The inner NormalizeDispatcher case-folds the schema name based on 
catalog capabilities, so
+    // the entity is stored under the normalized identifier. Normalize here 
too so ownership is
+    // attached to the identifiers the manager sees and ancestor probing 
matches stored names.
+    NameIdentifier normalizedIdent =
+        CapabilityHelpers.applyCapabilities(
+            ident, Capability.Scope.SCHEMA, 
GravitinoEnv.getInstance().catalogManager());
 
-    // Set the creator as the owner of the schema.
-    OwnerDispatcher ownerManager = 
GravitinoEnv.getInstance().ownerDispatcher();
-    if (ownerManager != null) {
-      // The inner NormalizeDispatcher case-folds the schema name 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.SCHEMA, 
GravitinoEnv.getInstance().catalogManager());
-      ownerManager.setOwner(
-          normalizedIdent.namespace().level(0),
-          NameIdentifierUtil.toMetadataObject(normalizedIdent, 
Entity.EntityType.SCHEMA),
-          PrincipalUtils.getCurrentUserName(),
-          Owner.Type.USER);
+    // Serialize probe -> create -> owner-assignment on the catalog so 
concurrent hierarchical
+    // creates cannot both claim a shared, newly-created ancestor (which would 
let the later create
+    // overwrite the first creator's ownership). We lock the catalog node -- 
the same node the inner
+    // SchemaOperationDispatcher.createSchema write-locks -- so the 
acquisition is reentrant; a
+    // deeper (branch-scoped) lock would hold the catalog node in READ mode 
and deadlock against
+    // that inner WRITE acquisition.
+    NameIdentifier catalogIdent =
+        NameIdentifierUtil.ofCatalog(
+            normalizedIdent.namespace().level(0), 
normalizedIdent.namespace().level(1));
+    return TreeLockUtils.doWithTreeLock(
+        catalogIdent,
+        LockType.WRITE,
+        () -> {
+          // For a hierarchical schema name (e.g. "A:B:C") the store 
auto-creates a row for each
+          // missing ancestor ("A", "A:B"). Probe BEFORE the create which 
ancestors are new, so
+          // ownership is assigned only to schemas this request actually 
creates and a pre-existing
+          // ancestor's owner is never overwritten.
+          List<NameIdentifier> newAncestors = 
findMissingAncestors(normalizedIdent);
+
+          Schema schema = dispatcher.createSchema(ident, comment, properties);
+
+          // Set the creator as the owner of the new schema and of any 
ancestors it created. This
+          // mirrors IcebergNamespaceHookDispatcher.createNamespace so 
ownership-based
+          // authorization -- which treats ownership of an ancestor schema as 
ownership of the
+          // whole subtree -- behaves the same on the Gravitino and Iceberg 
REST surfaces.
+          OwnerDispatcher ownerManager = 
GravitinoEnv.getInstance().ownerDispatcher();
+          if (ownerManager != null) {
+            List<MetadataObject> ownedObjects = new 
ArrayList<>(newAncestors.size() + 1);
+            for (NameIdentifier ancestor : newAncestors) {
+              ownedObjects.add(
+                  NameIdentifierUtil.toMetadataObject(ancestor, 
Entity.EntityType.SCHEMA));
+            }
+            ownedObjects.add(
+                NameIdentifierUtil.toMetadataObject(normalizedIdent, 
Entity.EntityType.SCHEMA));
+            // All objects are SCHEMA-typed, so the batch path (single object 
type) is valid.
+            ownerManager.setOwners(
+                normalizedIdent.namespace().level(0),
+                ownedObjects,
+                PrincipalUtils.getCurrentUserName(),
+                Owner.Type.USER);
+          }
+          return schema;
+        });
+  }
+
+  /**
+   * Returns the identifiers of the (already-normalized) ancestor schemas of 
{@code normalizedIdent}
+   * that do not yet exist, ordered outermost-to-innermost. Returns an empty 
list for a flat (non
+   * hierarchical) schema name.
+   *
+   * <p>This issues one {@code schemaExists} probe per missing ancestor while 
the caller holds the
+   * catalog WRITE lock, so it blocks other schema operations on the catalog 
for the duration. The
+   * cost is bounded by the schema nesting depth, which is expected to be 
small (typically 2-3
+   * levels); the innermost-to-outermost short-circuit below means a request 
that nests under an
+   * existing parent issues only a single probe. If much deeper nesting 
becomes common this should
+   * be revisited (e.g. a single prefix query instead of per-level probes).
+   */
+  private List<NameIdentifier> findMissingAncestors(NameIdentifier 
normalizedIdent) {
+    String separator = HierarchicalSchemaUtil.schemaSeparator();
+    String schemaName = normalizedIdent.name();
+    List<NameIdentifier> missing = new ArrayList<>();
+    if (!schemaName.contains(separator)) {
+      return missing;
+    }
+    String metalake = normalizedIdent.namespace().level(0);
+    String catalog = normalizedIdent.namespace().level(1);
+    List<String> ancestorNames = 
HierarchicalSchemaUtil.getAncestorNames(schemaName, separator);
+    // Walk innermost-to-outermost: in the hierarchical schema model the 
existence of an inner
+    // ancestor implies all of its outer ancestors exist, so we can stop 
probing at the first
+    // ancestor that already exists.
+    for (int i = ancestorNames.size() - 1; i >= 0; i--) {
+      NameIdentifier ancestorIdent =
+          NameIdentifierUtil.ofSchema(metalake, catalog, ancestorNames.get(i));
+      if (dispatcher.schemaExists(ancestorIdent)) {
+        break;
+      }
+      missing.add(ancestorIdent);
     }
-    return schema;
+    // Reverse to outermost-to-innermost, the order ownership assignment 
consumes.
+    Collections.reverse(missing);
+    return missing;
   }
 
   @Override
diff --git 
a/core/src/main/java/org/apache/gravitino/utils/HierarchicalSchemaUtil.java 
b/core/src/main/java/org/apache/gravitino/utils/HierarchicalSchemaUtil.java
index 4d6a3fae91..85bc82462d 100644
--- a/core/src/main/java/org/apache/gravitino/utils/HierarchicalSchemaUtil.java
+++ b/core/src/main/java/org/apache/gravitino/utils/HierarchicalSchemaUtil.java
@@ -111,6 +111,21 @@ public final class HierarchicalSchemaUtil {
     return StringUtils.isNotBlank(name) && name.contains(separator);
   }
 
+  /**
+   * Splits a (possibly hierarchical) schema name into its segments using the 
external separator.
+   * Trailing empty segments are preserved (split limit {@code -1}) so callers 
can detect malformed
+   * names that contain empty segments (e.g. {@code "A::B"} or {@code "A:"}).
+   *
+   * <p>Example: {@code "A:B:C"} with separator {@code ":"} → {@code ["A", 
"B", "C"]}
+   *
+   * @param name the schema name to split
+   * @param separator the external separator
+   * @return the segments of the schema name
+   */
+  public static String[] splitSchemaName(String name, String separator) {
+    return name.split(Pattern.quote(separator), -1);
+  }
+
   /**
    * Returns all ancestor schema names of the given schema name, ordered from 
outermost to innermost
    * (but excluding the name itself). Returns an empty list for top-level 
(non-HierarchicalSchema)
@@ -125,7 +140,7 @@ public final class HierarchicalSchemaUtil {
   public static List<String> getAncestorNames(String schemaName, String 
separator) {
     Preconditions.checkArgument(StringUtils.isNotBlank(schemaName), 
"schemaName must not be blank");
     Preconditions.checkArgument(StringUtils.isNotBlank(separator), "separator 
must not be blank");
-    String[] parts = schemaName.split(Pattern.quote(separator), -1);
+    String[] parts = splitSchemaName(schemaName, separator);
     List<String> ancestors = new ArrayList<>();
     for (int i = 1; i < parts.length; i++) {
       ancestors.add(String.join(separator, Arrays.copyOf(parts, i)));
diff --git 
a/core/src/test/java/org/apache/gravitino/catalog/TestSchemaOperationDispatcher.java
 
b/core/src/test/java/org/apache/gravitino/catalog/TestSchemaOperationDispatcher.java
index f57a8c4038..084eb6c105 100644
--- 
a/core/src/test/java/org/apache/gravitino/catalog/TestSchemaOperationDispatcher.java
+++ 
b/core/src/test/java/org/apache/gravitino/catalog/TestSchemaOperationDispatcher.java
@@ -295,4 +295,59 @@ public class TestSchemaOperationDispatcher extends 
TestOperationDispatcher {
     Assertions.assertThrows(
         RuntimeException.class, () -> dispatcher.dropSchema(schemaIdent, 
false));
   }
+
+  @Test
+  public void testDropHierarchicalSchemaCleansUpOrphanedAncestors() throws 
IOException {
+    // Clear any spy stubs leaked from other tests sharing the static 
entityStore.
+    reset(entityStore);
+    // Only the leaf "orphanA:orphanB:orphanC" is created in the catalog and 
the store. Names are
+    // unique to this test because the catalog connector keeps its schemas in 
shared static state.
+    NameIdentifier leaf = NameIdentifier.of(metalake, catalog, 
"orphanA:orphanB:orphanC");
+    dispatcher.createSchema(leaf, "comment", ImmutableMap.of("k1", "v1", "k2", 
"v2"));
+
+    // Simulate the ancestor entities the relational store auto-creates for a 
hierarchical name.
+    NameIdentifier ancestorAb = NameIdentifier.of(metalake, catalog, 
"orphanA:orphanB");
+    NameIdentifier ancestorA = NameIdentifier.of(metalake, catalog, "orphanA");
+    putSchemaEntity(ancestorAb);
+    putSchemaEntity(ancestorA);
+
+    // The catalog only knows the leaf, so dropping it leaves the ancestors 
orphaned in the store;
+    // since neither ancestor exists in the catalog, both entities must be 
cleaned up.
+    Assertions.assertTrue(dispatcher.dropSchema(leaf, false));
+    Assertions.assertFalse(entityStore.exists(ancestorAb, SCHEMA));
+    Assertions.assertFalse(entityStore.exists(ancestorA, SCHEMA));
+  }
+
+  @Test
+  public void testDropHierarchicalSchemaKeepsAncestorsThatStillExist() throws 
IOException {
+    // Clear any spy stubs leaked from other tests sharing the static 
entityStore.
+    reset(entityStore);
+    // Both the parent "keepA:keepB" and the leaf "keepA:keepB:keepC" exist in 
the catalog and the
+    // store. Names are unique to this test to avoid the connector's shared 
static schema state.
+    NameIdentifier parentAb = NameIdentifier.of(metalake, catalog, 
"keepA:keepB");
+    NameIdentifier leaf = NameIdentifier.of(metalake, catalog, 
"keepA:keepB:keepC");
+    dispatcher.createSchema(parentAb, "comment", ImmutableMap.of("k1", "v1", 
"k2", "v2"));
+    dispatcher.createSchema(leaf, "comment", ImmutableMap.of("k1", "v1", "k2", 
"v2"));
+
+    // Simulate the top-level ancestor entity.
+    NameIdentifier ancestorA = NameIdentifier.of(metalake, catalog, "keepA");
+    putSchemaEntity(ancestorA);
+
+    // "keepA:keepB" still exists in the catalog, so cleanup stops there and 
keeps it and "keepA".
+    Assertions.assertTrue(dispatcher.dropSchema(leaf, false));
+    Assertions.assertTrue(entityStore.exists(parentAb, SCHEMA));
+    Assertions.assertTrue(entityStore.exists(ancestorA, SCHEMA));
+  }
+
+  private void putSchemaEntity(NameIdentifier ident) throws IOException {
+    SchemaEntity entity =
+        SchemaEntity.builder()
+            .withId(idGenerator.nextId())
+            .withName(ident.name())
+            .withNamespace(ident.namespace())
+            .withAuditInfo(
+                
AuditInfo.builder().withCreator("tester").withCreateTime(Instant.now()).build())
+            .build();
+    entityStore.put(entity, true);
+  }
 }
diff --git 
a/core/src/test/java/org/apache/gravitino/hook/TestSchemaHookDispatcher.java 
b/core/src/test/java/org/apache/gravitino/hook/TestSchemaHookDispatcher.java
index ce14f9f4d0..3e05c3c83c 100644
--- a/core/src/test/java/org/apache/gravitino/hook/TestSchemaHookDispatcher.java
+++ b/core/src/test/java/org/apache/gravitino/hook/TestSchemaHookDispatcher.java
@@ -18,30 +18,45 @@
  */
 package org.apache.gravitino.hook;
 
+import static org.apache.gravitino.Configs.TREE_LOCK_CLEAN_INTERVAL;
+import static org.apache.gravitino.Configs.TREE_LOCK_MAX_NODE_IN_MEMORY;
+import static org.apache.gravitino.Configs.TREE_LOCK_MIN_NODE_IN_MEMORY;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
 import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+import com.google.common.collect.ImmutableList;
+import java.util.Arrays;
 import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
 import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.Entity;
 import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Schema;
+import org.apache.gravitino.authorization.AuthorizationUtils;
 import org.apache.gravitino.authorization.Owner;
 import org.apache.gravitino.authorization.OwnerDispatcher;
 import org.apache.gravitino.catalog.CatalogManager;
 import org.apache.gravitino.catalog.SchemaDispatcher;
 import org.apache.gravitino.connector.capability.Capability;
 import org.apache.gravitino.connector.capability.CapabilityResult;
+import org.apache.gravitino.lock.LockManager;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
 
 public class TestSchemaHookDispatcher {
 
@@ -54,6 +69,7 @@ public class TestSchemaHookDispatcher {
   // state into the GravitinoEnv singleton across tests.
   private OwnerDispatcher savedOwnerDispatcher;
   private CatalogManager savedCatalogManager;
+  private LockManager savedLockManager;
 
   @BeforeEach
   public void setUp() throws Exception {
@@ -70,8 +86,13 @@ public class TestSchemaHookDispatcher {
     // initialized. Read the field directly via reflection to capture the 
current value safely.
     savedCatalogManager =
         (CatalogManager) FieldUtils.readField(GravitinoEnv.getInstance(), 
"catalogManager", true);
+    savedLockManager =
+        (LockManager) FieldUtils.readField(GravitinoEnv.getInstance(), 
"lockManager", true);
     FieldUtils.writeField(GravitinoEnv.getInstance(), "ownerDispatcher", 
mockOwnerDispatcher, true);
     FieldUtils.writeField(GravitinoEnv.getInstance(), "catalogManager", 
mockCatalogManager, true);
+    // createSchema now acquires a catalog-level tree lock, so wire up a real 
LockManager.
+    FieldUtils.writeField(
+        GravitinoEnv.getInstance(), "lockManager", new 
LockManager(newLockConfig()), true);
     hookDispatcher = new SchemaHookDispatcher(mockDispatcher);
   }
 
@@ -80,6 +101,15 @@ public class TestSchemaHookDispatcher {
     FieldUtils.writeField(
         GravitinoEnv.getInstance(), "ownerDispatcher", savedOwnerDispatcher, 
true);
     FieldUtils.writeField(GravitinoEnv.getInstance(), "catalogManager", 
savedCatalogManager, true);
+    FieldUtils.writeField(GravitinoEnv.getInstance(), "lockManager", 
savedLockManager, true);
+  }
+
+  private static Config newLockConfig() {
+    Config config = mock(Config.class);
+    doReturn(100000L).when(config).get(TREE_LOCK_MAX_NODE_IN_MEMORY);
+    doReturn(1000L).when(config).get(TREE_LOCK_MIN_NODE_IN_MEMORY);
+    doReturn(36000L).when(config).get(TREE_LOCK_CLEAN_INTERVAL);
+    return config;
   }
 
   @Test
@@ -90,7 +120,7 @@ public class TestSchemaHookDispatcher {
 
     doThrow(new RuntimeException("Set owner failed"))
         .when(mockOwnerDispatcher)
-        .setOwner(any(), any(), any(), any());
+        .setOwners(any(), anyList(), any(), any());
 
     RuntimeException thrown =
         Assertions.assertThrows(
@@ -103,7 +133,7 @@ public class TestSchemaHookDispatcher {
   @Test
   public void testCreateSchemaSetsOwnerWithNormalizedIdentifier() throws 
Exception {
     // Use a case-insensitive capability so the schema name is normalized to 
lower case before
-    // setOwner is called, mirroring what NormalizeDispatcher would do for the 
manager.
+    // setOwners is called, mirroring what NormalizeDispatcher would do for 
the manager.
     when(mockCatalogWrapper.capabilities()).thenReturn(new 
CaseInsensitiveCapability());
 
     NameIdentifier ident = NameIdentifier.of("test_metalake", "test_catalog", 
"MY_SCHEMA");
@@ -112,28 +142,115 @@ public class TestSchemaHookDispatcher {
 
     hookDispatcher.createSchema(ident, "comment", Collections.emptyMap());
 
-    ArgumentCaptor<MetadataObject> captor = 
ArgumentCaptor.forClass(MetadataObject.class);
-    verify(mockOwnerDispatcher)
-        .setOwner(eq("test_metalake"), captor.capture(), any(), 
eq(Owner.Type.USER));
+    List<MetadataObject> owned = captureOwnedObjects();
+    Assertions.assertEquals(1, owned.size(), "A flat schema only assigns 
ownership to the leaf");
     Assertions.assertEquals(
         "my_schema",
-        captor.getValue().name(),
-        "Schema name passed to setOwner must be lowercased by 
Capability.Scope.SCHEMA"
+        owned.get(0).name(),
+        "Schema name passed to setOwners must be lowercased by 
Capability.Scope.SCHEMA"
             + " normalization");
     // Schema's namespace is [metalake, catalog]; 
NameIdentifierUtil.toMetadataObject uses
     // level(1) as parent. Catalog is not subject to per-scope name 
normalization here, so
     // parent is just the catalog name -- there is no schema component to 
normalize.
     Assertions.assertEquals(
         "test_catalog",
-        captor.getValue().parent(),
+        owned.get(0).parent(),
         "Schema parent must be the catalog name (level(1) of the namespace); 
SCHEMA's namespace"
             + " has no schema component to normalize");
   }
 
+  @Test
+  public void testCreateHierarchicalSchemaOwnsNewAncestors() throws Exception {
+    // A capability that permits hierarchical (":"-separated) schema names so 
the hierarchical name
+    // is not rejected during normalization.
+    when(mockCatalogWrapper.capabilities()).thenReturn(new 
HierarchicalCapability());
+
+    NameIdentifier ident = NameIdentifier.of("test_metalake", "test_catalog", 
"A:B:C");
+    Schema mockSchema = mock(Schema.class);
+    when(mockDispatcher.createSchema(any(), any(), 
any())).thenReturn(mockSchema);
+    // No ancestor exists yet, so creating "A:B:C" auto-creates "A" and "A:B".
+    when(mockDispatcher.schemaExists(any())).thenReturn(false);
+
+    hookDispatcher.createSchema(ident, "comment", Collections.emptyMap());
+
+    List<String> ownedNames =
+        
captureOwnedObjects().stream().map(MetadataObject::name).collect(Collectors.toList());
+    Assertions.assertEquals(
+        Arrays.asList("A", "A:B", "A:B:C"),
+        ownedNames,
+        "Creator must own every newly-created ancestor plus the leaf, 
outermost-to-innermost");
+  }
+
+  @Test
+  public void testCreateHierarchicalSchemaKeepsExistingAncestorOwner() throws 
Exception {
+    when(mockCatalogWrapper.capabilities()).thenReturn(new 
HierarchicalCapability());
+
+    NameIdentifier ident = NameIdentifier.of("test_metalake", "test_catalog", 
"A:B:C");
+    Schema mockSchema = mock(Schema.class);
+    when(mockDispatcher.createSchema(any(), any(), 
any())).thenReturn(mockSchema);
+    // "A" already exists (and has its own owner); only "A:B" and the leaf are 
newly created.
+    NameIdentifier existingA = NameIdentifier.of("test_metalake", 
"test_catalog", "A");
+    when(mockDispatcher.schemaExists(any())).thenReturn(false);
+    when(mockDispatcher.schemaExists(eq(existingA))).thenReturn(true);
+
+    hookDispatcher.createSchema(ident, "comment", Collections.emptyMap());
+
+    List<String> ownedNames =
+        
captureOwnedObjects().stream().map(MetadataObject::name).collect(Collectors.toList());
+    Assertions.assertEquals(
+        Arrays.asList("A:B", "A:B:C"),
+        ownedNames,
+        "Pre-existing ancestor 'A' must keep its owner; only newly-created 
schemas are claimed");
+  }
+
+  @Test
+  public void testDropSchemaRemovesPrivileges() {
+    NameIdentifier ident = NameIdentifier.of("test_metalake", "test_catalog", 
"A:B:C");
+    when(mockDispatcher.dropSchema(eq(ident), eq(false))).thenReturn(true);
+
+    try (MockedStatic<AuthorizationUtils> authz = 
Mockito.mockStatic(AuthorizationUtils.class)) {
+      authz
+          .when(
+              () ->
+                  AuthorizationUtils.getMetadataObjectLocation(
+                      any(NameIdentifier.class), any(Entity.EntityType.class)))
+          .thenReturn(ImmutableList.of("/test"));
+
+      boolean dropped = hookDispatcher.dropSchema(ident, false);
+
+      Assertions.assertTrue(dropped, "Drop result must be propagated from the 
inner dispatcher");
+      verify(mockDispatcher).dropSchema(eq(ident), eq(false));
+      // Privileges for the dropped schema must be removed.
+      authz.verify(
+          () ->
+              AuthorizationUtils.authorizationPluginRemovePrivileges(
+                  eq(ident), eq(Entity.EntityType.SCHEMA), 
eq(ImmutableList.of("/test"))));
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  private List<MetadataObject> captureOwnedObjects() {
+    ArgumentCaptor<List<MetadataObject>> captor = 
ArgumentCaptor.forClass(List.class);
+    verify(mockOwnerDispatcher)
+        .setOwners(eq("test_metalake"), captor.capture(), any(), 
eq(Owner.Type.USER));
+    return captor.getValue();
+  }
+
   private static class CaseInsensitiveCapability implements Capability {
     @Override
     public CapabilityResult caseSensitiveOnName(Scope scope) {
       return CapabilityResult.unsupported("case-insensitive");
     }
   }
+
+  /** Accepts hierarchical SCHEMA names so normalization does not reject 
":"-separated names. */
+  private static class HierarchicalCapability implements Capability {
+    @Override
+    public CapabilityResult specificationOnName(Scope scope, String name) {
+      if (scope == Scope.SCHEMA) {
+        return CapabilityResult.SUPPORTED;
+      }
+      return Capability.super.specificationOnName(scope, name);
+    }
+  }
 }
diff --git 
a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/util/BaseIT.java
 
b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/util/BaseIT.java
index b003b6e5f8..5f328fb023 100644
--- 
a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/util/BaseIT.java
+++ 
b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/util/BaseIT.java
@@ -119,11 +119,15 @@ public class BaseIT {
   public static final String DOWNLOAD_CLICKHOUSE_JDBC_DRIVER_URL =
       
"https://repo1.maven.org/maven2/com/clickhouse/clickhouse-jdbc/0.7.1/clickhouse-jdbc-0.7.1-all.jar";;
 
+  public static final String DOWNLOAD_SQLITE_JDBC_DRIVER_URL =
+      
"https://repo1.maven.org/maven2/org/xerial/sqlite-jdbc/3.42.0.0/sqlite-jdbc-3.42.0.0.jar";;
+
   public static final Map<String, Pattern> 
SUPPORTED_CLEAN_CONFLICTS_DRIVER_TYPES =
       ImmutableMap.of(
           "mysql", Pattern.compile("mysql-connector-java-([\\d.]+)\\.jar"),
           "postgresql", Pattern.compile("postgresql-([\\d.]+)\\.jar"),
-          "clickhouse", 
Pattern.compile("clickhouse-jdbc-([\\d.]+)(-all)?\\.jar"));
+          "clickhouse", 
Pattern.compile("clickhouse-jdbc-([\\d.]+)(-all)?\\.jar"),
+          "sqlite", Pattern.compile("sqlite-jdbc-([\\d.]+)\\.jar"));
 
   private TestDatabaseName META_DATA;
   private MySQLContainer MYSQL_CONTAINER;
@@ -201,7 +205,8 @@ public class BaseIT {
     String[] driverUrls = {
       DOWNLOAD_MYSQL_JDBC_DRIVER_URL,
       DOWNLOAD_POSTGRESQL_JDBC_DRIVER_URL,
-      DOWNLOAD_CLICKHOUSE_JDBC_DRIVER_URL
+      DOWNLOAD_CLICKHOUSE_JDBC_DRIVER_URL,
+      DOWNLOAD_SQLITE_JDBC_DRIVER_URL
     };
     String[] dirs = getJdbcDriverDownloadDirs();
     downloadJdbcDrivers(driverUrls, dirs);
diff --git 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/AuthorizationRequest.java
 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/AuthorizationRequest.java
index 93105922eb..3d84fa84f2 100644
--- 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/AuthorizationRequest.java
+++ 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/AuthorizationRequest.java
@@ -33,6 +33,7 @@ public @interface AuthorizationRequest {
     ASSOCIATE_TAG,
     ASSOCIATE_POLICY,
     RUN_JOB,
-    LOAD_TABLE
+    LOAD_TABLE,
+    CREATE_SCHEMA
   }
 }
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/AuthorizeExecutorFactory.java
 
b/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/AuthorizeExecutorFactory.java
index 7b13d63c2f..5badd704d4 100644
--- 
a/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/AuthorizeExecutorFactory.java
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/AuthorizeExecutorFactory.java
@@ -54,6 +54,8 @@ public class AuthorizeExecutorFactory {
           entityType,
           secondaryExpression,
           secondaryExpressionCondition);
+      case CREATE_SCHEMA -> new CreateSchemaAuthorizationExecutor(
+          parameters, args, expression, metadataContext, pathParams, 
entityType);
     };
   }
 }
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/CreateSchemaAuthorizationExecutor.java
 
b/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/CreateSchemaAuthorizationExecutor.java
new file mode 100644
index 0000000000..31799bb2e7
--- /dev/null
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/CreateSchemaAuthorizationExecutor.java
@@ -0,0 +1,105 @@
+/*
+ * 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.authorization;
+
+import java.lang.reflect.Parameter;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Optional;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.dto.requests.SchemaCreateRequest;
+import 
org.apache.gravitino.server.authorization.annotations.AuthorizationRequest;
+import org.apache.gravitino.utils.HierarchicalSchemaUtil;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+
+/**
+ * Authorization executor for {@code createSchema} operations.
+ *
+ * <p>For hierarchical schema names (e.g. {@code A:B:C}), injects the parent 
schema ({@code A:B})
+ * into the metadata context so that the standard expression can evaluate 
{@code CREATE_SCHEMA}
+ * against the already-existing parent. {@link
+ * org.apache.gravitino.server.authorization.jcasbin.JcasbinAuthorizer} then 
walks the inheritance
+ * chain ({@code A:B → A → CATALOG}) automatically.
+ *
+ * <p>For top-level schemas no SCHEMA is injected; the expression falls back 
to CATALOG-level
+ * checks.
+ */
+public class CreateSchemaAuthorizationExecutor extends 
CommonAuthorizerExecutor {
+
+  /**
+   * Creates an authorization executor for a {@code createSchema} request and, 
for hierarchical
+   * schema names, injects the parent schema into the metadata context so the 
standard expression
+   * can evaluate {@code CREATE_SCHEMA} against the already-existing parent.
+   *
+   * @param parameters the parameters of the intercepted method
+   * @param args the arguments passed to the intercepted method
+   * @param expression the authorization expression to evaluate
+   * @param metadataContext the mutable metadata context bound to the 
authorization expression
+   * @param pathParams the path parameters of the request
+   * @param entityType the optional entity type of the request
+   */
+  public CreateSchemaAuthorizationExecutor(
+      Parameter[] parameters,
+      Object[] args,
+      String expression,
+      Map<Entity.EntityType, NameIdentifier> metadataContext,
+      Map<String, Object> pathParams,
+      Optional<String> entityType) {
+    super(expression, metadataContext, pathParams, entityType);
+    injectParentSchema(parameters, args);
+  }
+
+  private void injectParentSchema(Parameter[] parameters, Object[] args) {
+    SchemaCreateRequest request = extractRequest(parameters, args);
+    // Skip injection when the name is missing/blank; let 
SchemaCreateRequest.validate() surface the
+    // intended 400 instead of throwing during authorization interception 
(returned as a 500).
+    if (request == null || StringUtils.isBlank(request.getName())) {
+      return;
+    }
+
+    NameIdentifier catalogIdent = 
metadataContext.get(Entity.EntityType.CATALOG);
+    if (catalogIdent == null) {
+      return;
+    }
+    String metalake = catalogIdent.namespace().level(0);
+    String catalog = catalogIdent.name();
+
+    String separator = HierarchicalSchemaUtil.schemaSeparator();
+    String schemaName = request.getName();
+    String[] levels = HierarchicalSchemaUtil.splitSchemaName(schemaName, 
separator);
+
+    if (levels.length > 1) {
+      String parentPath = String.join(separator, Arrays.copyOf(levels, 
levels.length - 1));
+      metadataContext.put(
+          Entity.EntityType.SCHEMA, NameIdentifierUtil.ofSchema(metalake, 
catalog, parentPath));
+    }
+  }
+
+  private SchemaCreateRequest extractRequest(Parameter[] parameters, Object[] 
args) {
+    for (int i = 0; i < parameters.length; i++) {
+      AuthorizationRequest annotation = 
parameters[i].getAnnotation(AuthorizationRequest.class);
+      if (annotation != null
+          && annotation.type() == 
AuthorizationRequest.RequestType.CREATE_SCHEMA) {
+        return (SchemaCreateRequest) args[i];
+      }
+    }
+    return null;
+  }
+}
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/rest/SchemaOperations.java
 
b/server/src/main/java/org/apache/gravitino/server/web/rest/SchemaOperations.java
index bbacfdf9f5..5b7f52ad89 100644
--- 
a/server/src/main/java/org/apache/gravitino/server/web/rest/SchemaOperations.java
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/rest/SchemaOperations.java
@@ -35,6 +35,7 @@ import javax.ws.rs.QueryParam;
 import javax.ws.rs.core.Context;
 import javax.ws.rs.core.MediaType;
 import javax.ws.rs.core.Response;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.NameIdentifier;
@@ -53,8 +54,10 @@ import org.apache.gravitino.metrics.MetricNames;
 import org.apache.gravitino.server.authorization.MetadataAuthzHelper;
 import 
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
 import 
org.apache.gravitino.server.authorization.annotations.AuthorizationMetadata;
+import 
org.apache.gravitino.server.authorization.annotations.AuthorizationRequest;
 import 
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants;
 import org.apache.gravitino.server.web.Utils;
+import org.apache.gravitino.utils.HierarchicalSchemaUtil;
 import org.apache.gravitino.utils.NameIdentifierUtil;
 import org.apache.gravitino.utils.NamespaceUtil;
 import org.slf4j.Logger;
@@ -86,14 +89,24 @@ public class SchemaOperations {
   public Response listSchemas(
       @PathParam("metalake") @AuthorizationMetadata(type = 
Entity.EntityType.METALAKE)
           String metalake,
-      @PathParam("catalog") @AuthorizationMetadata(type = 
Entity.EntityType.CATALOG)
-          String catalog) {
-    LOG.info("Received list schema request for catalog: {}.{}", metalake, 
catalog);
+      @PathParam("catalog") @AuthorizationMetadata(type = 
Entity.EntityType.CATALOG) String catalog,
+      @DefaultValue("") @QueryParam("parentSchema") String parentSchema) {
+    LOG.info(
+        "Received list schema request for catalog: {}.{}, parentSchema: {}",
+        metalake,
+        catalog,
+        parentSchema);
     try {
       return Utils.doAs(
           httpRequest,
           () -> {
-            Namespace schemaNS = NamespaceUtil.ofSchema(metalake, catalog);
+            Namespace schemaNS;
+            if (StringUtils.isBlank(parentSchema)) {
+              schemaNS = NamespaceUtil.ofSchema(metalake, catalog);
+            } else {
+              validateParentSchema(parentSchema);
+              schemaNS = Namespace.of(metalake, catalog, parentSchema);
+            }
             NameIdentifier[] idents = dispatcher.listSchemas(schemaNS);
             idents =
                 MetadataAuthzHelper.filterByExpression(
@@ -102,7 +115,12 @@ public class SchemaOperations {
                     Entity.EntityType.SCHEMA,
                     idents);
             Response response = Utils.ok(new EntityListResponse(idents));
-            LOG.info("List {} schemas in catalog {}.{}", idents.length, 
metalake, catalog);
+            LOG.info(
+                "List {} schemas in catalog {}.{} (parentSchema='{}')",
+                idents.length,
+                metalake,
+                catalog,
+                parentSchema);
             return response;
           });
     } catch (Exception e) {
@@ -115,13 +133,14 @@ public class SchemaOperations {
   @Timed(name = "create-schema." + MetricNames.HTTP_PROCESS_DURATION, absolute 
= true)
   @ResponseMetered(name = "create-schema", absolute = true)
   @AuthorizationExpression(
-      expression = "ANY(OWNER, METALAKE, CATALOG) || ANY_USE_CATALOG && 
ANY_CREATE_SCHEMA",
-      accessMetadataType = MetadataObject.Type.CATALOG)
+      expression = "ANY(OWNER, METALAKE, CATALOG, SCHEMA) || ANY_USE_CATALOG 
&& ANY_CREATE_SCHEMA",
+      accessMetadataType = MetadataObject.Type.SCHEMA)
   public Response createSchema(
       @PathParam("metalake") @AuthorizationMetadata(type = 
Entity.EntityType.METALAKE)
           String metalake,
       @PathParam("catalog") @AuthorizationMetadata(type = 
Entity.EntityType.CATALOG) String catalog,
-      SchemaCreateRequest request) {
+      @AuthorizationRequest(type = 
AuthorizationRequest.RequestType.CREATE_SCHEMA)
+          SchemaCreateRequest request) {
     LOG.info("Received create schema request: {}.{}.{}", metalake, catalog, 
request.getName());
     try {
       return Utils.doAs(
@@ -244,4 +263,24 @@ public class SchemaOperations {
       return ExceptionHandlers.handleSchemaException(OperationType.DROP, 
schema, catalog, e);
     }
   }
+
+  /**
+   * Validates the {@code parentSchema} query parameter. The value is a 
logical (possibly
+   * hierarchical) schema name, so it must not contain empty segments (e.g. 
{@code "A::B"} or {@code
+   * "A:"}) before it is passed to {@link Namespace#of}.
+   *
+   * @param parentSchema the non-blank {@code parentSchema} query parameter
+   * @throws IllegalArgumentException if the value contains an empty segment
+   */
+  private static void validateParentSchema(String parentSchema) {
+    String separator = HierarchicalSchemaUtil.schemaSeparator();
+    for (String segment : HierarchicalSchemaUtil.splitSchemaName(parentSchema, 
separator)) {
+      if (segment.isEmpty()) {
+        throw new IllegalArgumentException(
+            String.format(
+                "The parentSchema '%s' contains an empty segment after 
splitting by '%s'.",
+                parentSchema, separator));
+      }
+    }
+  }
 }
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestSchemaOperations.java
 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestSchemaOperations.java
index 02ce325f3b..8981e5e29d 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestSchemaOperations.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestSchemaOperations.java
@@ -20,6 +20,7 @@ package org.apache.gravitino.server.web.rest;
 
 import static org.apache.gravitino.Configs.CACHE_ENABLED;
 import static org.apache.gravitino.Configs.ENABLE_AUTHORIZATION;
+import static org.apache.gravitino.Configs.SCHEMA_SEPARATOR;
 import static org.apache.gravitino.Configs.TREE_LOCK_CLEAN_INTERVAL;
 import static org.apache.gravitino.Configs.TREE_LOCK_MAX_NODE_IN_MEMORY;
 import static org.apache.gravitino.Configs.TREE_LOCK_MIN_NODE_IN_MEMORY;
@@ -27,6 +28,8 @@ import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 import com.google.common.collect.ImmutableList;
@@ -43,6 +46,7 @@ import org.apache.gravitino.Audit;
 import org.apache.gravitino.Config;
 import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.Namespace;
 import org.apache.gravitino.Schema;
 import org.apache.gravitino.catalog.SchemaDispatcher;
 import org.apache.gravitino.catalog.SchemaOperationDispatcher;
@@ -94,6 +98,7 @@ public class TestSchemaOperations extends BaseOperationsTest {
     Mockito.doReturn(36000L).when(config).get(TREE_LOCK_CLEAN_INTERVAL);
     Mockito.doReturn(false).when(config).get(CACHE_ENABLED);
     Mockito.doReturn(false).when(config).get(ENABLE_AUTHORIZATION);
+    Mockito.doReturn(":").when(config).get(SCHEMA_SEPARATOR);
     FieldUtils.writeField(GravitinoEnv.getInstance(), "config", config, true);
     FieldUtils.writeField(GravitinoEnv.getInstance(), "lockManager", new 
LockManager(config), true);
   }
@@ -177,6 +182,41 @@ public class TestSchemaOperations extends 
BaseOperationsTest {
     Assertions.assertEquals(RuntimeException.class.getSimpleName(), 
errorResp2.getType());
   }
 
+  @Test
+  public void testListSchemasWithParentSchemaPassToDispatcher() {
+    NameIdentifier ident = NameIdentifier.of(metalake, catalog, "A:sales");
+    when(dispatcher.listSchemas(any())).thenReturn(new NameIdentifier[] 
{ident});
+
+    Response resp =
+        target("/metalakes/" + metalake + "/catalogs/" + catalog + "/schemas")
+            .queryParam("parentSchema", "A")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .get();
+
+    Assertions.assertEquals(Response.Status.OK.getStatusCode(), 
resp.getStatus());
+    EntityListResponse listResp = resp.readEntity(EntityListResponse.class);
+    Assertions.assertEquals(0, listResp.getCode());
+    Assertions.assertEquals(1, listResp.identifiers().length);
+    Assertions.assertEquals("A:sales", listResp.identifiers()[0].name());
+
+    verify(dispatcher).listSchemas(eq(Namespace.of(metalake, catalog, "A")));
+  }
+
+  @Test
+  public void testListSchemasWithMalformedParentSchemaReturnsBadRequest() {
+    Response resp =
+        target("/metalakes/" + metalake + "/catalogs/" + catalog + "/schemas")
+            .queryParam("parentSchema", "A::B")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .get();
+
+    Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), 
resp.getStatus());
+    // The malformed parentSchema must be rejected before reaching the 
dispatcher.
+    verify(dispatcher, never()).listSchemas(any());
+  }
+
   @Test
   public void testCreateSchema() {
     SchemaCreateRequest req =

Reply via email to