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 f00f45c33c [#14562] fix(iceberg-rest): reject dotted namespace levels
before authorization (#14566)
f00f45c33c is described below
commit f00f45c33c52f642f61bea6c5aa086d4b57f5387
Author: roryqi <[email protected]>
AuthorDate: Tue Nov 3 18:49:18 2026 +0800
[#14562] fix(iceberg-rest): reject dotted namespace levels before
authorization (#14566)
### What changes were proposed in this pull request?
Share namespace-level validation between creation and authorization.
Validate decoded namespace levels before authorization expression
evaluation, and validate create requests before resolving their parent
schema.
### Why are the changes needed?
Requests naming a namespace level containing `.` currently fail with an
internal authorization error. Reject these invalid names as bad requests
before they reach metadata-object conversion or the catalog.
Fixes #14562
### Does this PR introduce _any_ user-facing change?
Invalid namespace requests return HTTP 400 instead of 500. Encoded or
literal `..` paths rejected during routing may return 404.
### How was this patch tested?
- 130 targeted Iceberg REST tests passed, covering authorization,
request-body handlers, namespace creation and operations, REST
utilities, and exception mapping.
- 24 HTTP requests cover GET, HEAD, property updates and DELETE with
both explicit and default catalog paths. Inputs include `a%2Eb`,
`%2e%2e` and literal `..`; raw socket requests preserve `..` without
client normalization. The tests verify that the backend dispatcher is
not called.
- Spotless and `git diff --check` passed.
---
.../iceberg/service/IcebergRESTUtils.java | 17 ++
.../dispatcher/IcebergNamespaceHookDispatcher.java | 10 +-
.../web/filter/CreateNamespaceAuthzHandler.java | 2 +
...bergMetadataAuthorizationMethodInterceptor.java | 1 +
.../iceberg/service/TestIcebergRESTUtils.java | 29 ++++
.../TestIcebergNamespaceAuthorizationPaths.java | 177 +++++++++++++++++++++
...bergMetadataAuthorizationMethodInterceptor.java | 71 +++++++++
.../TestIcebergRequestBodyAuthzHandlers.java | 37 +++++
8 files changed, 336 insertions(+), 8 deletions(-)
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
index d38cea75bf..5b0d479675 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
@@ -118,6 +118,23 @@ public class IcebergRESTUtils {
private IcebergRESTUtils() {}
+ /**
+ * Checks that namespace levels can be represented in Gravitino metadata
object names.
+ *
+ * @param namespace the decoded namespace to check
+ * @throws BadRequestException if a level contains the reserved
qualified-name separator
+ */
+ public static void checkNamespaceLevels(Namespace namespace) {
+ for (String level : namespace.levels()) {
+ if (level.contains(".")) {
+ throw new BadRequestException(
+ "Invalid namespace %s: the level '%s' contains '.', which is
reserved as the"
+ + " qualified-name separator",
+ namespace, level);
+ }
+ }
+ }
+
/**
* Builds an Iceberg REST {@link
org.apache.iceberg.rest.credentials.Credential} for load-table,
* scan-plan, or credentials API responses.
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceHookDispatcher.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceHookDispatcher.java
index 8394307c8b..2a520098b0 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceHookDispatcher.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceHookDispatcher.java
@@ -32,6 +32,7 @@ import org.apache.gravitino.catalog.SchemaDispatcher;
import org.apache.gravitino.catalog.TableDispatcher;
import org.apache.gravitino.catalog.ViewDispatcher;
import org.apache.gravitino.iceberg.common.utils.IcebergIdentifierUtils;
+import org.apache.gravitino.iceberg.service.IcebergRESTUtils;
import
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
import org.apache.gravitino.listener.api.event.IcebergRequestContext;
import org.apache.gravitino.lock.LockType;
@@ -124,14 +125,7 @@ public class IcebergNamespaceHookDispatcher implements
IcebergNamespaceOperation
if (GravitinoEnv.getInstance().internalSchemaDispatcher() == null) {
return;
}
- for (String level : namespace.levels()) {
- if (level.contains(".")) {
- throw new BadRequestException(
- "Invalid namespace %s: the level '%s' contains '.', which is
reserved as the"
- + " qualified-name separator",
- namespace, level);
- }
- }
+ IcebergRESTUtils.checkNamespaceLevels(namespace);
String name = String.join(HierarchicalSchemaUtil.schemaSeparator(),
namespace.levels());
if (name.codePointCount(0, name.length()) >
EntityFieldLimits.MAX_NAME_LENGTH) {
throw new BadRequestException(
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/CreateNamespaceAuthzHandler.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/CreateNamespaceAuthzHandler.java
index 1d12ee4364..b7761e5ae7 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/CreateNamespaceAuthzHandler.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/CreateNamespaceAuthzHandler.java
@@ -24,6 +24,7 @@ import java.util.Arrays;
import java.util.Map;
import org.apache.gravitino.Entity.EntityType;
import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.iceberg.service.IcebergRESTUtils;
import
org.apache.gravitino.server.authorization.annotations.IcebergAuthorizationMetadata;
import
org.apache.gravitino.server.web.filter.BaseMetadataAuthorizationMethodInterceptor.AuthorizationHandler;
import org.apache.gravitino.utils.HierarchicalSchemaUtil;
@@ -67,6 +68,7 @@ public class CreateNamespaceAuthzHandler implements
AuthorizationHandler {
String catalog = catalogIdent.name();
Namespace namespace = request.namespace();
+ IcebergRESTUtils.checkNamespaceLevels(namespace);
int levels = namespace.length();
String separator = HierarchicalSchemaUtil.schemaSeparator();
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java
index baeb09901d..66cefe6a58 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java
@@ -106,6 +106,7 @@ public class IcebergMetadataAuthorizationMethodInterceptor
rawNamespace =
RESTUtil.decodeNamespace(
value,
IcebergRESTUtils.NAMESPACE_SEPARATOR_URLENCODED_UTF_8);
+ IcebergRESTUtils.checkNamespaceLevels(rawNamespace);
schema = String.join(separator, rawNamespace.levels());
nameIdentifierMap.put(
Entity.EntityType.SCHEMA,
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergRESTUtils.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergRESTUtils.java
index ac57441f55..10a76eaa84 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergRESTUtils.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergRESTUtils.java
@@ -62,6 +62,8 @@ import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mockito;
@SuppressWarnings("deprecation")
@@ -88,6 +90,24 @@ public class TestIcebergRESTUtils {
NameIdentifier.of(metalakeName, catalogName, "ns1", "ns2", "table"),
nameIdentifier);
}
+ @ParameterizedTest
+ @MethodSource("invalidNamespaces")
+ void testRejectsDotInAnyNamespaceLevel(Namespace namespace) {
+ BadRequestException error =
+ Assertions.assertThrows(
+ BadRequestException.class, () ->
IcebergRESTUtils.checkNamespaceLevels(namespace));
+ Assertions.assertTrue(error.getMessage().contains("contains '.'"));
+ }
+
+ @Test
+ void testAllowsValidNamespaceLevelsWithoutDecodingAgain() {
+ Assertions.assertDoesNotThrow(() ->
IcebergRESTUtils.checkNamespaceLevels(Namespace.empty()));
+ Assertions.assertDoesNotThrow(
+ () -> IcebergRESTUtils.checkNamespaceLevels(Namespace.of("parent",
"child")));
+ Assertions.assertDoesNotThrow(
+ () ->
IcebergRESTUtils.checkNamespaceLevels(Namespace.of("literal%2Ename")));
+ }
+
@Test
void testGetCatalogName() {
String prefix = "catalog/";
@@ -519,4 +539,13 @@ public class TestIcebergRESTUtils {
RESTRequest valid = () -> {};
Assertions.assertDoesNotThrow(() ->
IcebergRESTUtils.validateRequest(valid, "X"));
}
+
+ private static List<Namespace> invalidNamespaces() {
+ return List.of(
+ Namespace.of("a.b"),
+ Namespace.of("."),
+ Namespace.of(".."),
+ Namespace.of("a.b", "child"),
+ Namespace.of("parent", "a.b"));
+ }
}
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergNamespaceAuthorizationPaths.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergNamespaceAuthorizationPaths.java
new file mode 100644
index 0000000000..d85e499a9c
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergNamespaceAuthorizationPaths.java
@@ -0,0 +1,177 @@
+/*
+ * 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.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import javax.inject.Singleton;
+import javax.ws.rs.core.Application;
+import org.apache.gravitino.iceberg.service.IcebergExceptionMapper;
+import org.apache.gravitino.iceberg.service.IcebergObjectMapper;
+import org.apache.gravitino.iceberg.service.IcebergObjectMapperProvider;
+import
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
+import
org.apache.gravitino.iceberg.service.dispatcher.IcebergNamespaceOperationDispatcher;
+import org.apache.gravitino.iceberg.service.provider.IcebergConfigProvider;
+import org.apache.gravitino.rest.RESTUtils;
+import
org.apache.gravitino.server.web.filter.IcebergRESTAuthInterceptionService;
+import org.apache.iceberg.rest.responses.ErrorResponse;
+import org.glassfish.hk2.api.InterceptionService;
+import org.glassfish.hk2.utilities.binding.AbstractBinder;
+import org.glassfish.jersey.jackson.JacksonFeature;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.JerseyTest;
+import org.glassfish.jersey.test.TestProperties;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/** Tests encoded and literal dotted namespace paths through HTTP and the
authorization proxy. */
+public class TestIcebergNamespaceAuthorizationPaths extends JerseyTest {
+
+ private static final String UPDATE_BODY = "{\"updates\":{\"k\":\"v\"}}";
+
+ private IcebergNamespaceOperationDispatcher dispatcher;
+
+ /**
+ * Registers the production namespace resource and authorization
interception service.
+ *
+ * @return the test application
+ */
+ @Override
+ protected Application configure() {
+ try {
+ forceSet(
+ TestProperties.CONTAINER_PORT,
String.valueOf(RESTUtils.findAvailablePort(2000, 3000)));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ IcebergConfigProvider provider = mock(IcebergConfigProvider.class);
+ when(provider.getMetalakeName()).thenReturn("metalake");
+ when(provider.getDefaultCatalogName()).thenReturn("catalog");
+ IcebergRESTServerContext.create(provider, true, true, false, null);
+ dispatcher = mock(IcebergNamespaceOperationDispatcher.class);
+ return new ResourceConfig(IcebergNamespaceOperations.class)
+ .register(IcebergExceptionMapper.class)
+ .register(IcebergObjectMapperProvider.class)
+ .register(JacksonFeature.withoutExceptionMappers())
+ .register(
+ new AbstractBinder() {
+ /** {@inheritDoc} */
+ @Override
+ protected void configure() {
+ bind(dispatcher).to(IcebergNamespaceOperationDispatcher.class);
+ bind(IcebergRESTAuthInterceptionService.class)
+ .to(InterceptionService.class)
+ .in(Singleton.class);
+ }
+ });
+ }
+
+ @ParameterizedTest
+ @CsvSource({"catalog/, a%2Eb", "'', a%2Eb", "catalog/, %2e%2e", "'',
%2e%2e"})
+ void testEncodedDotPathsNeverReturnInternalError(String prefix, String
namespace)
+ throws Exception {
+ HttpClient client =
+ HttpClient.newBuilder()
+ .version(HttpClient.Version.HTTP_1_1)
+ .connectTimeout(Duration.ofSeconds(5))
+ .build();
+ for (String method : new String[] {"GET", "HEAD", "POST", "DELETE"}) {
+ String path = namespacePath(prefix, namespace, method);
+ HttpRequest request =
+ HttpRequest.newBuilder(URI.create(getBaseUri() + path))
+ .timeout(Duration.ofSeconds(10))
+ .header("Accept", "application/json")
+ .header("Content-Type", "application/json")
+ .method(
+ method,
+ method.equals("POST")
+ ? HttpRequest.BodyPublishers.ofString(UPDATE_BODY)
+ : HttpRequest.BodyPublishers.noBody())
+ .build();
+ HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
+ assertTrue(
+ response.statusCode() == 400 || response.statusCode() == 404,
+ () -> method + " " + path + ": " + response.statusCode() + " " +
response.body());
+ if (namespace.equals("a%2Eb")) {
+ assertEquals(400, response.statusCode());
+ if (!method.equals("HEAD")) {
+ ErrorResponse error =
+ IcebergObjectMapper.getInstance().readValue(response.body(),
ErrorResponse.class);
+ assertEquals(400, error.code());
+ assertEquals("BadRequestException", error.type());
+ }
+ }
+ }
+ verifyNoInteractions(dispatcher);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"catalog/", ""})
+ void testLiteralDotDotPathWithoutClientNormalization(String prefix) throws
IOException {
+ for (String method : new String[] {"GET", "HEAD", "POST", "DELETE"}) {
+ String path = "/" + namespacePath(prefix, "..", method);
+ String body = method.equals("POST") ? UPDATE_BODY : "";
+ // Send the request target directly so the client cannot normalize the
'..' segment away.
+ String request =
+ method
+ + " "
+ + path
+ + " HTTP/1.1\r\nHost: localhost\r\nContent-Type:
application/json\r\n"
+ + "Connection: close\r\nContent-Length: "
+ + body.length()
+ + "\r\n\r\n"
+ + body;
+ try (Socket socket = new Socket()) {
+ socket.connect(new InetSocketAddress(getBaseUri().getHost(),
getBaseUri().getPort()), 5000);
+ socket.setSoTimeout(10000);
+
socket.getOutputStream().write(request.getBytes(StandardCharsets.US_ASCII));
+ socket.getOutputStream().flush();
+ String response =
+ new String(socket.getInputStream().readAllBytes(),
StandardCharsets.UTF_8);
+ String statusLine = response.split("\r\n", 2)[0];
+ assertTrue(
+ statusLine.startsWith("HTTP/1.1 400") ||
statusLine.startsWith("HTTP/1.1 404"),
+ () -> method + " " + path + ": " + statusLine);
+ }
+ }
+ verifyNoInteractions(dispatcher);
+ }
+
+ private static String namespacePath(String prefix, String namespace, String
method) {
+ return "v1/"
+ + prefix
+ + "namespaces/"
+ + namespace
+ + (method.equals("POST") ? "/properties" : "");
+ }
+}
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java
index b9d25aa64b..6e2b26fd05 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java
@@ -30,6 +30,7 @@ import java.lang.reflect.Parameter;
import java.util.Collections;
import java.util.Map;
import java.util.Optional;
+import java.util.stream.Stream;
import javax.ws.rs.core.Response;
import org.aopalliance.intercept.MethodInvocation;
import org.apache.commons.lang3.reflect.FieldUtils;
@@ -40,6 +41,7 @@ import org.apache.gravitino.GravitinoEnv;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.UserPrincipal;
import org.apache.gravitino.auth.ActiveRoles;
+import org.apache.gravitino.authorization.AuthorizationUtils;
import org.apache.gravitino.authorization.GravitinoAuthorizer;
import org.apache.gravitino.exceptions.NoSuchCatalogException;
import org.apache.gravitino.iceberg.service.CatalogWrapperForREST;
@@ -48,6 +50,7 @@ import org.apache.gravitino.iceberg.service.IcebergRESTUtils;
import
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
import org.apache.gravitino.iceberg.service.provider.IcebergConfigProvider;
import org.apache.gravitino.iceberg.service.rest.IcebergConfigOperations;
+import org.apache.gravitino.iceberg.service.rest.IcebergNamespaceOperations;
import org.apache.gravitino.iceberg.service.rest.IcebergTableOperations;
import org.apache.gravitino.server.authorization.GravitinoAuthorizerProvider;
import
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
@@ -61,12 +64,16 @@ import org.apache.iceberg.exceptions.ForbiddenException;
import org.apache.iceberg.rest.RESTCatalog;
import org.apache.iceberg.rest.RESTUtil;
import org.apache.iceberg.rest.requests.ReportMetricsRequest;
+import org.apache.iceberg.rest.requests.UpdateNamespacePropertiesRequest;
import org.apache.iceberg.rest.responses.ErrorResponse;
import org.glassfish.hk2.api.ActiveDescriptor;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
@@ -135,6 +142,54 @@ public class
TestIcebergMetadataAuthorizationMethodInterceptor {
tableId.toString());
}
+ @ParameterizedTest
+ @MethodSource("invalidNamespaceRequests")
+ void testInvalidNamespaceIsBadRequestBeforeAuthorization(String operation,
String namespace)
+ throws Throwable {
+ resetContext(null, false);
+ boolean update = operation.equals("updateNamespace");
+ Method method =
+ update
+ ? IcebergNamespaceOperations.class.getMethod(
+ operation, String.class, String.class,
UpdateNamespacePropertiesRequest.class)
+ : IcebergNamespaceOperations.class.getMethod(operation,
String.class, String.class);
+ Object[] args =
+ update
+ ? new Object[] {
+ TEST_CATALOG + "/", namespace,
Mockito.mock(UpdateNamespacePropertiesRequest.class)
+ }
+ : new Object[] {TEST_CATALOG + "/", namespace};
+ MethodInvocation invocation = Mockito.mock(MethodInvocation.class);
+ Mockito.when(invocation.getMethod()).thenReturn(method);
+ Mockito.when(invocation.getArguments()).thenReturn(args);
+ IcebergMetadataAuthorizationMethodInterceptor interceptor =
+ new IcebergMetadataAuthorizationMethodInterceptor();
+
+ try (MockedStatic<AuthorizationUtils> authorization =
+ Mockito.mockStatic(AuthorizationUtils.class);
+ Response response = (Response) interceptor.invoke(invocation)) {
+ assertEquals(400, response.getStatus());
+ ErrorResponse error = (ErrorResponse) response.getEntity();
+ assertEquals(400, error.code());
+ assertEquals("BadRequestException", error.type());
+ assertTrue(error.message().contains("contains '.'"));
+ authorization.verifyNoInteractions();
+ }
+ Mockito.verify(invocation, Mockito.never()).proceed();
+ }
+
+ @Test
+ void testNamespaceLevelsAreDecodedOnlyOnce() throws Exception {
+ IcebergMetadataAuthorizationMethodInterceptor interceptor =
+ new IcebergMetadataAuthorizationMethodInterceptor();
+ Method method =
+ IcebergNamespaceOperations.class.getMethod("loadNamespace",
String.class, String.class);
+ Map<Entity.EntityType, NameIdentifier> identifiers =
+ interceptor.extractNameIdentifierFromParameters(
+ method.getParameters(), new Object[] {TEST_CATALOG + "/",
"a%252Eb"});
+ assertEquals("a%2Eb", identifiers.get(Entity.EntityType.SCHEMA).name());
+ }
+
@Test
public void testExtractTableNameIdentifierWithSpecialCharacters() throws
Exception {
IcebergMetadataAuthorizationMethodInterceptor interceptor =
@@ -615,6 +670,22 @@ public class
TestIcebergMetadataAuthorizationMethodInterceptor {
}
}
+ private static Stream<Arguments> invalidNamespaceRequests() {
+ return Stream.of("loadNamespace", "namespaceExists", "updateNamespace",
"dropNamespace")
+ .flatMap(
+ operation ->
+ Stream.of(
+ "a.b",
+ "a%2Eb",
+ "a%2eb",
+ ".",
+ "..",
+ "%2e%2e",
+ "parent%1Fa.b",
+ "a%2Eb%1Fchild")
+ .map(namespace -> Arguments.of(operation, namespace)));
+ }
+
/** Test operations class to provide method annotations for testing. */
@SuppressWarnings("unused")
public static class TestOperations {
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergRequestBodyAuthzHandlers.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergRequestBodyAuthzHandlers.java
index a34ccf037f..0ef678a3f8 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergRequestBodyAuthzHandlers.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergRequestBodyAuthzHandlers.java
@@ -19,11 +19,17 @@
package org.apache.gravitino.server.web.filter;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.HashMap;
+import java.util.Map;
+import java.util.stream.Stream;
+import org.apache.gravitino.Entity.EntityType;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.iceberg.service.rest.IcebergNamespaceOperations;
import
org.apache.gravitino.server.authorization.annotations.IcebergAuthorizationMetadata;
import
org.apache.gravitino.server.authorization.annotations.IcebergAuthorizationMetadata.RequestType;
import org.apache.iceberg.catalog.Namespace;
@@ -32,6 +38,8 @@ import org.apache.iceberg.exceptions.BadRequestException;
import org.apache.iceberg.rest.requests.CreateNamespaceRequest;
import org.apache.iceberg.rest.requests.RenameTableRequest;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mockito;
/**
@@ -77,6 +85,27 @@ public class TestIcebergRequestBodyAuthzHandlers {
}
}
+ @ParameterizedTest
+ @MethodSource("invalidNamespaceLevels")
+ void testCreateRejectsDottedLevelsBeforeResolvingParent(Namespace namespace)
throws Exception {
+ Method method =
+ IcebergNamespaceOperations.class.getMethod(
+ "createNamespace", String.class, CreateNamespaceRequest.class);
+ CreateNamespaceRequest request =
+ CreateNamespaceRequest.builder().withNamespace(namespace).build();
+ NameIdentifier catalog = NameIdentifier.of("metalake", "catalog");
+ Map<EntityType, NameIdentifier> identifiers = new HashMap<>();
+ identifiers.put(EntityType.CATALOG, catalog);
+
+ assertThrows(
+ BadRequestException.class,
+ () ->
+ new CreateNamespaceAuthzHandler(
+ method.getParameters(), new Object[] {"catalog/", request})
+ .process(identifiers));
+ assertEquals(Map.of(EntityType.CATALOG, catalog), identifiers);
+ }
+
@Test
public void testRenameViewWithoutSourceOrDestinationIsBadRequest() throws
Exception {
Parameter[] parameters = parameters("renameView",
RenameTableRequest.class);
@@ -103,6 +132,14 @@ public class TestIcebergRequestBodyAuthzHandlers {
return method.getParameters();
}
+ private static Stream<Namespace> invalidNamespaceLevels() {
+ return Stream.of(
+ Namespace.of("a.b"),
+ Namespace.of("a.b", "child"),
+ Namespace.of("parent", "a.b"),
+ Namespace.of("parent", "a.b", "child"));
+ }
+
/** Provides annotated parameters like the REST operations do. */
@SuppressWarnings("unused")
public static class TestOperations {