This is an automated email from the ASF dual-hosted git repository.
roryqi pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/branch-1.3 by this push:
new 50e33f6e56 [Cherry-pick to branch-1.3] [#14444] fix(iceberg-rest):
keep namespaces whose levels contain ':' from sharing a governed identity
(#14446)
50e33f6e56 is described below
commit 50e33f6e5642da29d4a0dd815da2a4551665da87
Author: Mark Hoerth <[email protected]>
AuthorDate: Sun Nov 1 18:49:10 2026 -0800
[Cherry-pick to branch-1.3] [#14444] fix(iceberg-rest): keep namespaces
whose levels contain ':' from sharing a governed identity (#14446)
Backport to branch-1.3 of the fix for #14444 (same change as the PR to
main; branch-1.3 has no register-view route).
### What changes were proposed in this pull request?
- `IcebergNamespaceOperationExecutor.createNamespace`: with
authorization enabled, refuse a namespace level that contains the schema
separator with 400 before anything is created.
- `IcebergNamespaceOperations.filterListNamespacesResponse`: filter the
namespaces as the catalog listed them (using the generic
`MetadataAuthzHelper.filterByExpression` overload) instead of rebuilding
them from the Gravitino schema name, which split `["x:y"]` into
`["x","y"]`.
A namespace with such a level that already exists in a catalog keeps
sharing its identity with the nested namespace; an administrator should
rename or drop it.
### Why are the changes needed?
`["x:y"]` and `["x","y"]` were governed as the same Gravitino schema, so
creating one gave the creator ownership of the other and of its tables.
Fixes #14444
### Does this PR introduce _any_ user-facing change?
Yes. With authorization enabled, creating a namespace whose level
contains the schema separator (`:` by default) returns 400.
### How was this patch tested?
-
`TestIcebergNamespaceOperationExecutor#testCreateNamespaceRefusesSchemaSeparatorInLevelWithAuthorization`:
refused with authorization, the nested namespace still accepted,
accepted without authorization.
- `TestIcebergNamespaceListFilter`: listed namespaces come back
unchanged, and filtering still applies.
`./gradlew :iceberg:iceberg-rest-server:test -PskipITs`
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Eu6fb9pLfmrSJzGQDVLi1g
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../IcebergNamespaceOperationExecutor.java | 37 +++++++++++
.../service/rest/IcebergNamespaceOperations.java | 50 +++++++--------
.../TestIcebergNamespaceOperationExecutor.java | 32 +++++++++
.../rest/TestIcebergNamespaceListFilter.java | 75 ++++++++++++++++++++++
4 files changed, 167 insertions(+), 27 deletions(-)
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceOperationExecutor.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceOperationExecutor.java
index d69b5a30e4..94cd918b34 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceOperationExecutor.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceOperationExecutor.java
@@ -19,6 +19,7 @@
package org.apache.gravitino.iceberg.service.dispatcher;
+import com.google.common.annotations.VisibleForTesting;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
@@ -27,11 +28,14 @@ import org.apache.gravitino.auth.AuthConstants;
import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
import org.apache.gravitino.iceberg.service.CatalogWrapperForREST;
import org.apache.gravitino.iceberg.service.IcebergCatalogWrapperManager;
+import
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
import org.apache.gravitino.iceberg.service.cleanup.IcebergCleanupManager;
import org.apache.gravitino.listener.api.event.IcebergRequestContext;
import org.apache.gravitino.meta.TableEntity;
+import org.apache.gravitino.utils.HierarchicalSchemaUtil;
import org.apache.iceberg.TableMetadata;
import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.exceptions.BadRequestException;
import org.apache.iceberg.rest.requests.CreateNamespaceRequest;
import org.apache.iceberg.rest.requests.RegisterTableRequest;
import org.apache.iceberg.rest.requests.UpdateNamespacePropertiesRequest;
@@ -61,6 +65,7 @@ public class IcebergNamespaceOperationExecutor implements
IcebergNamespaceOperat
@Override
public CreateNamespaceResponse createNamespace(
IcebergRequestContext context, CreateNamespaceRequest
createNamespaceRequest) {
+ checkLevelsDoNotContainSchemaSeparator(createNamespaceRequest.namespace());
String authenticatedUser = context.userName();
if (!AuthConstants.ANONYMOUS_USER.equals(authenticatedUser)) {
String existingOwner =
createNamespaceRequest.properties().get(IcebergConstants.OWNER);
@@ -142,4 +147,36 @@ public class IcebergNamespaceOperationExecutor implements
IcebergNamespaceOperat
return catalogWrapper.registerTable(
namespace, registerTableRequest, context.requestCredentialVending());
}
+
+ /**
+ * With authorization enabled, a namespace is governed as the Gravitino
schema named by its levels
+ * joined with the schema separator. A level that contains the separator
would give two different
+ * namespaces, such as {@code ["a:b"]} and {@code ["a", "b"]}, the same
schema, and so the same
+ * owner and privileges: whoever creates one would take over the other. Such
a level is refused
+ * before anything is created.
+ */
+ @VisibleForTesting
+ static void checkLevelsDoNotContainSchemaSeparator(Namespace namespace) {
+ if (namespace == null || !isAuthorizationEnabled()) {
+ return;
+ }
+ String separator = HierarchicalSchemaUtil.schemaSeparator();
+ for (String level : namespace.levels()) {
+ if (level.contains(separator)) {
+ throw new BadRequestException(
+ "Invalid namespace %s: the level '%s' contains '%s', which
separates the levels of a"
+ + " nested namespace",
+ namespace, level, separator);
+ }
+ }
+ }
+
+ private static boolean isAuthorizationEnabled() {
+ try {
+ return IcebergRESTServerContext.getInstance().isAuthorizationEnabled();
+ } catch (IllegalStateException e) {
+ // The server context is not initialized, as in a standalone executor.
+ return false;
+ }
+ }
}
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergNamespaceOperations.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergNamespaceOperations.java
index 405d26c484..49da1f1643 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergNamespaceOperations.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergNamespaceOperations.java
@@ -23,10 +23,9 @@ import com.codahale.metrics.annotation.Timed;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.annotations.VisibleForTesting;
-import java.util.ArrayList;
+import java.util.Arrays;
import java.util.List;
import java.util.Optional;
-import java.util.regex.Pattern;
import javax.inject.Inject;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.Consumes;
@@ -353,37 +352,34 @@ public class IcebergNamespaceOperations {
}
}
- private NameIdentifier[] toNameIdentifiers(
- ListNamespacesResponse listNamespacesResponse, String metalake, String
catalogName) {
- String separator = HierarchicalSchemaUtil.schemaSeparator();
- List<Namespace> namespaces = listNamespacesResponse.namespaces();
- NameIdentifier[] nameIdentifiers = new NameIdentifier[namespaces.size()];
- for (int i = 0; i < namespaces.size(); i++) {
- Namespace namespace = namespaces.get(i);
- // Join namespace levels with the logical separator to form the
Gravitino schema name,
- // e.g. Namespace.of("A","B","C") -> "A:B:C" -> NameIdentifier(metalake,
catalog, "A:B:C").
- String schemaName = namespace.isEmpty() ? "" : String.join(separator,
namespace.levels());
- nameIdentifiers[i] = NameIdentifier.of(metalake, catalogName,
schemaName);
- }
- return nameIdentifiers;
- }
-
- private ListNamespacesResponse filterListNamespacesResponse(
+ /**
+ * Keeps the namespaces the caller may see, as the catalog listed them.
+ *
+ * @param listNamespacesResponse the namespaces the catalog returned
+ * @param metalake the metalake of the catalog
+ * @param catalogName the catalog
+ * @return the namespaces the caller may see
+ */
+ @VisibleForTesting
+ static ListNamespacesResponse filterListNamespacesResponse(
ListNamespacesResponse listNamespacesResponse, String metalake, String
catalogName) {
String separator = HierarchicalSchemaUtil.schemaSeparator();
- NameIdentifier[] idents =
+ Namespace[] namespaces =
+ listNamespacesResponse.namespaces().stream()
+ .filter(namespace -> !namespace.isEmpty())
+ .toArray(Namespace[]::new);
+ // Filter the namespaces as the catalog listed them. Rebuilding them from
the Gravitino schema
+ // name would split a level that contains the separator into several
levels.
+ Namespace[] filtered =
MetadataAuthzHelper.filterByExpression(
metalake,
AuthorizationExpressionConstants.FILTER_SCHEMA_AUTHORIZATION_EXPRESSION,
Entity.EntityType.SCHEMA,
- toNameIdentifiers(listNamespacesResponse, metalake, catalogName));
- List<Namespace> filteredNamespaces = new ArrayList<>();
- for (NameIdentifier ident : idents) {
- if (ident.hasNamespace() && ident.namespace().levels().length >= 2) {
- // Convert the logical schema name (e.g. "A:B:C") back to a
multi-level Iceberg Namespace.
-
filteredNamespaces.add(Namespace.of(ident.name().split(Pattern.quote(separator),
-1)));
- }
- }
+ namespaces,
+ namespace ->
+ NameIdentifier.of(
+ metalake, catalogName, String.join(separator,
namespace.levels())));
+ List<Namespace> filteredNamespaces = Arrays.asList(filtered);
return ListNamespacesResponse.builder().addAll(filteredNamespaces).build();
}
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergNamespaceOperationExecutor.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergNamespaceOperationExecutor.java
index 7fa75a4df8..f51d53b219 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergNamespaceOperationExecutor.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergNamespaceOperationExecutor.java
@@ -39,6 +39,8 @@ import
org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
import org.apache.gravitino.iceberg.common.IcebergConfig;
import org.apache.gravitino.iceberg.service.CatalogWrapperForREST;
import org.apache.gravitino.iceberg.service.IcebergCatalogWrapperManager;
+import
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
+import org.apache.gravitino.iceberg.service.provider.IcebergConfigProvider;
import org.apache.gravitino.listener.api.event.IcebergRequestContext;
import org.apache.iceberg.PartitionSpec;
import org.apache.iceberg.Schema;
@@ -46,6 +48,7 @@ import org.apache.iceberg.TableMetadata;
import org.apache.iceberg.TableMetadataParser;
import org.apache.iceberg.catalog.Namespace;
import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.exceptions.BadRequestException;
import org.apache.iceberg.rest.requests.CreateNamespaceRequest;
import org.apache.iceberg.rest.requests.ImmutableRegisterTableRequest;
import org.apache.iceberg.rest.requests.RegisterTableRequest;
@@ -385,4 +388,33 @@ public class TestIcebergNamespaceOperationExecutor {
Files.writeString(metadataFile, TableMetadataParser.toJson(metadata),
StandardCharsets.UTF_8);
return metadataFile.toUri().toString();
}
+
+ @Test
+ public void
testCreateNamespaceRefusesSchemaSeparatorInLevelWithAuthorization() {
+ IcebergConfigProvider provider = mock(IcebergConfigProvider.class);
+ when(provider.getMetalakeName()).thenReturn("metalake");
+ when(provider.getDefaultCatalogName()).thenReturn("test_catalog");
+ when(mockContext.userName()).thenReturn("user");
+
when(mockCatalogWrapper.createNamespace(any())).thenReturn(mock(CreateNamespaceResponse.class));
+ try {
+ IcebergRESTServerContext.create(provider, true, false, false,
mockWrapperManager);
+ CreateNamespaceRequest colliding =
+
CreateNamespaceRequest.builder().withNamespace(Namespace.of("x:y")).build();
+ Assertions.assertThrows(
+ BadRequestException.class, () ->
executor.createNamespace(mockContext, colliding));
+ verify(mockCatalogWrapper, never()).createNamespace(any());
+
+ // The nested namespace the level would collide with is still accepted.
+ executor.createNamespace(
+ mockContext,
+ CreateNamespaceRequest.builder().withNamespace(Namespace.of("x",
"y")).build());
+ verify(mockCatalogWrapper).createNamespace(any());
+
+ // Without authorization there is no Gravitino schema to collide with.
+ IcebergRESTServerContext.create(provider, false, false, false,
mockWrapperManager);
+ Assertions.assertDoesNotThrow(() ->
executor.createNamespace(mockContext, colliding));
+ } finally {
+ IcebergRESTServerContext.create(provider, false, false, false, null);
+ }
+ }
}
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergNamespaceListFilter.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergNamespaceListFilter.java
new file mode 100644
index 0000000000..2ca296acb6
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergNamespaceListFilter.java
@@ -0,0 +1,75 @@
+/*
+ * 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.iceberg.service.rest;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Function;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.server.authorization.MetadataAuthzHelper;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.rest.responses.ListNamespacesResponse;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+/** Tests that the authorization filter returns listed namespaces unchanged. */
+public class TestIcebergNamespaceListFilter {
+
+ @Test
+ public void testLevelContainingSeparatorIsNotSplit() {
+ List<Namespace> listed =
+ Arrays.asList(Namespace.of("x:y"), Namespace.of("x", "y"),
Namespace.of("a"));
+ ListNamespacesResponse response =
ListNamespacesResponse.builder().addAll(listed).build();
+
+ // Everything visible: the namespaces come back exactly as listed.
+ Assertions.assertEquals(listed, filter(response, null).namespaces());
+
+ // Only the Gravitino schema "a" is visible.
+ Assertions.assertEquals(
+ Arrays.asList(Namespace.of("a")),
+ filter(response, Set.of(NameIdentifier.of("m", "c",
"a"))).namespaces());
+ }
+
+ @SuppressWarnings("unchecked")
+ private static ListNamespacesResponse filter(
+ ListNamespacesResponse response, Set<NameIdentifier> visible) {
+ try (MockedStatic<MetadataAuthzHelper> helper =
Mockito.mockStatic(MetadataAuthzHelper.class)) {
+ helper
+ .when(
+ () ->
+ MetadataAuthzHelper.filterByExpression(
+ anyString(), anyString(), any(), any(Namespace[].class),
any(Function.class)))
+ .thenAnswer(
+ invocation -> {
+ Namespace[] namespaces = invocation.getArgument(3);
+ Function<Namespace, NameIdentifier> toIdent =
invocation.getArgument(4);
+ return Arrays.stream(namespaces)
+ .filter(ns -> visible == null ||
visible.contains(toIdent.apply(ns)))
+ .toArray(Namespace[]::new);
+ });
+ return IcebergNamespaceOperations.filterListNamespacesResponse(response,
"m", "c");
+ }
+ }
+}