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

yuqi1129 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 c2526d8b9e [Cherry-pick to branch-1.3] [#12760] fix(server): Cover 
root-mounted servlets with the request-context, audit, and custom filter chain 
(#12922) (#12945)
c2526d8b9e is described below

commit c2526d8b9ebdb4dc38867fccbeb393a9bcca9c2a
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Mon Sep 7 18:01:16 2026 +0800

    [Cherry-pick to branch-1.3] [#12760] fix(server): Cover root-mounted 
servlets with the request-context, audit, and custom filter chain (#12922) 
(#12945)
    
    **Cherry-pick Information:**
    - Original commit: b639f5c12f458817949a13ea3af1dfb7a9e9dd0c
    - Target branch: `branch-1.3`
    - Status: ✅ Conflicts resolved
    
    **Conflict resolution notes:**
    `branch-1.3`'s `LanceRESTService` predates several main-only Lance
    features (metadata authorization, `LanceServiceIdentityFilter`,
    `LanceHealthCheckPathMatcher`, `HealthAliasServlet`, `auxMode`-aware
    namespace loading), so the cherry-picked diff's context couldn't match.
    Resolved by dropping those unrelated additions from the "theirs" side
    while keeping the actual fix: the extracted
    `registerMetricsPathFilters()` helper wiring `RequestContextFilter` and
    `HttpAuditFilter` onto `JettyServer.METRICS_PATH_SPECS`, and
    consolidating custom-filter registration into a single
    `addCustomFilters()` call across `METRICS_PATH_SPECS` plus `LANCE_SPEC`.
    
    Similarly, `TestGravitinoServer.java` on main carries secret-provider
    discovery tests (`SecretProviderRegistry`, `InMemorySecretsProvider`, an
    HTTP-based `fetchSecretProviders` helper) that don't exist on
    `branch-1.3`. Dropped that unrelated test content while keeping the two
    new GH-12760 regression tests
    (`testEveryServletPathIsCoveredByAuditFilter`,
    `testEveryServletPathIsEitherAuthenticatedOrDeliberatelyPublic`) and
    their helpers.
    
    Also added the `testArtifacts` configuration to
    `server-common/build.gradle.kts`, which main already had from an earlier
    change never backported to `branch-1.3`; without it, consumers
    (`server`, `lance-rest-server`, `iceberg-rest-server`) couldn't resolve
    their existing `testImplementation(project(":server-common",
    "testArtifacts"))` dependency needed to reach the new
    `JettyServerTestUtils` class.
    
    Verified `:server:compileTestJava`,
    `:lance:lance-rest-server:compileJava`,
    `:lance:lance-rest-server:compileTestJava`, and
    `:iceberg:iceberg-rest-server:compileTestJava` all succeed, and
    `TestGravitinoServer`, `TestLanceRESTService`, and `TestRESTService` all
    pass.
    
    ---------
    
    Co-authored-by: Jerry Shao <[email protected]>
    Co-authored-by: Claude Sonnet 5 <[email protected]>
    Co-authored-by: Jerry Shao <[email protected]>
---
 iceberg/iceberg-rest-server/build.gradle.kts       |   1 +
 .../org/apache/gravitino/iceberg/RESTService.java  |  37 ++++++-
 .../apache/gravitino/iceberg/TestRESTService.java  |  77 +++++++++++++
 lance/lance-rest-server/build.gradle.kts           |   1 +
 .../apache/gravitino/lance/LanceRESTService.java   |  32 +++++-
 .../gravitino/lance/TestLanceRESTService.java      |  77 +++++++++++++
 server-common/build.gradle.kts                     |  13 +++
 .../apache/gravitino/server/web/JettyServer.java   |  29 ++++-
 .../gravitino/server/web/JettyServerTestUtils.java |  73 ++++++++++++
 server/build.gradle.kts                            |   1 +
 .../apache/gravitino/server/GravitinoServer.java   |  49 +++++++-
 .../gravitino/server/TestGravitinoServer.java      | 123 +++++++++++++++++++++
 12 files changed, 504 insertions(+), 9 deletions(-)

diff --git a/iceberg/iceberg-rest-server/build.gradle.kts 
b/iceberg/iceberg-rest-server/build.gradle.kts
index 59485de5ca..f69bb0ea66 100644
--- a/iceberg/iceberg-rest-server/build.gradle.kts
+++ b/iceberg/iceberg-rest-server/build.gradle.kts
@@ -84,6 +84,7 @@ dependencies {
   testImplementation(project(":bundles:iceberg-azure-bundle"))
   testImplementation(project(":core", "testArtifacts"))
   testImplementation(project(":integration-test-common", "testArtifacts"))
+  testImplementation(project(":server-common", "testArtifacts"))
   testImplementation(project(":server"))
 
   
testImplementation("org.scala-lang.modules:scala-collection-compat_$scalaVersion:$scalaCollectionCompatVersion")
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
index af732fbea1..89f42ca0c2 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
@@ -19,6 +19,7 @@
 package org.apache.gravitino.iceberg;
 
 import com.google.common.collect.Lists;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -214,13 +215,45 @@ public class RESTService implements 
GravitinoAuxiliaryService {
             EventSource.GRAVITINO_ICEBERG_REST_SERVER,
             new IcebergHealthCheckPathMatcher()),
         ICEBERG_SPEC);
-    server.addCustomFilters(ICEBERG_SPEC);
     server.addSystemFilters(ICEBERG_SPEC);
 
     // Root-level aliases for health checks to improve compatibility with 
various monitoring
-    // systems that expect a /health endpoint.
+    // systems that expect a /health endpoint. Not part of 
JettyServer.METRICS_PATH_SPECS below:
+    // HealthAliasServlet forwards every request into /iceberg/health*, which 
ICEBERG_SPEC already
+    // covers via the servlet container's FORWARD dispatcher type, so binding 
the filter again
+    // here would double-log every probe.
     server.addServlet(new HealthAliasServlet("/iceberg"), "/health/*");
     server.addServlet(new HealthAliasServlet("/iceberg"), "/health.html");
+
+    registerMetricsPathFilters(server, eventBus);
+
+    // Custom filters are registered once, across every filtered path in a 
single call, so a
+    // filter whose init() isn't safe to run more than once per JVM only runs 
it once rather than
+    // once per pathSpec.
+    List<String> customFilterPaths = new 
ArrayList<>(JettyServer.METRICS_PATH_SPECS);
+    customFilterPaths.add(ICEBERG_SPEC);
+    server.addCustomFilters(customFilterPaths.toArray(new String[0]));
+  }
+
+  /**
+   * Registers request-context tracking and audit-on-failure coverage on {@link
+   * JettyServer#METRICS_PATH_SPECS}. {@code /metrics} and {@code 
/prometheus/metrics} used to
+   * receive no such coverage at all, with nothing in the build catching it; 
{@code
+   * RequestContextFilter} is included too so query-parameter capture applies 
uniformly, matching
+   * {@link #ICEBERG_SPEC}. Package-private and static so a unit test can 
exercise it directly
+   * against a plain {@link JettyServer}, without booting the rest of {@link 
#initServer}. See
+   * GH-12760.
+   *
+   * @param server the Jetty server whose {@link 
JettyServer#METRICS_PATH_SPECS} need filter
+   *     coverage
+   * @param eventBus the event bus audit events are dispatched through
+   */
+  static void registerMetricsPathFilters(JettyServer server, EventBus 
eventBus) {
+    for (String pathSpec : JettyServer.METRICS_PATH_SPECS) {
+      server.addFilter(new RequestContextFilter(eventBus), pathSpec);
+      server.addFilter(
+          new HttpAuditFilter(eventBus, 
EventSource.GRAVITINO_ICEBERG_REST_SERVER), pathSpec);
+    }
   }
 
   @Override
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/TestRESTService.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/TestRESTService.java
new file mode 100644
index 0000000000..36b2b5e6bd
--- /dev/null
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/TestRESTService.java
@@ -0,0 +1,77 @@
+/*
+ * 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;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Collections;
+import java.util.Set;
+import org.apache.gravitino.iceberg.common.IcebergConfig;
+import org.apache.gravitino.listener.EventBus;
+import org.apache.gravitino.server.web.HttpAuditFilter;
+import org.apache.gravitino.server.web.JettyServer;
+import org.apache.gravitino.server.web.JettyServerConfig;
+import org.apache.gravitino.server.web.JettyServerTestUtils;
+import org.apache.gravitino.server.web.RequestContextFilter;
+import org.eclipse.jetty.servlet.ServletHandler;
+import org.junit.jupiter.api.Test;
+
+public class TestRESTService {
+
+  /**
+   * RESTService.initServer() previously registered /metrics and 
/prometheus/metrics (added by
+   * JettyServer#initialize() itself, outside ICEBERG_SPEC) with no audit 
coverage at all, and
+   * nothing in the build caught it. Rather than parse source text, this 
exercises the extracted
+   * RESTService#registerMetricsPathFilters against a plain JettyServer and 
inspects the real
+   * ServletHandler filter mappings it produces, so a broken 
JettyServer.METRICS_PATH_SPECS list or
+   * a filter that merely appears in a comment cannot pass. See GH-12760.
+   */
+  @Test
+  public void testMetricsPathsHaveAuditFilterCoverage() throws Exception {
+    JettyServer server = new JettyServer();
+    JettyServerConfig jettyServerConfig = JettyServerConfig.fromConfig(new 
IcebergConfig());
+    server.initialize(jettyServerConfig, "test-iceberg-rest", false);
+    EventBus eventBus = new EventBus(Collections.emptyList());
+
+    try {
+      RESTService.registerMetricsPathFilters(server, eventBus);
+
+      ServletHandler servletHandler =
+          
JettyServerTestUtils.getServletContextHandler(server).getServletHandler();
+      Set<String> auditedPathSpecs =
+          JettyServerTestUtils.filterPathSpecsFor(servletHandler, 
HttpAuditFilter.class);
+      Set<String> requestContextPathSpecs =
+          JettyServerTestUtils.filterPathSpecsFor(servletHandler, 
RequestContextFilter.class);
+
+      for (String pathSpec : JettyServer.METRICS_PATH_SPECS) {
+        assertTrue(
+            auditedPathSpecs.contains(pathSpec),
+            "'" + pathSpec + "' must be covered by HttpAuditFilter, see 
GH-12760");
+        assertTrue(
+            requestContextPathSpecs.contains(pathSpec),
+            "'"
+                + pathSpec
+                + "' must be covered by RequestContextFilter for 
query-parameter "
+                + "capture, see GH-12760");
+      }
+    } finally {
+      server.stop();
+    }
+  }
+}
diff --git a/lance/lance-rest-server/build.gradle.kts 
b/lance/lance-rest-server/build.gradle.kts
index 127eac64d5..55ede19641 100644
--- a/lance/lance-rest-server/build.gradle.kts
+++ b/lance/lance-rest-server/build.gradle.kts
@@ -103,6 +103,7 @@ dependencies {
   testImplementation(project(":clients:client-java"))
   testImplementation(project(":server"))
   testImplementation(project(":integration-test-common", "testArtifacts"))
+  testImplementation(project(":server-common", "testArtifacts"))
   testImplementation(libs.lance)
 
   lanceSparkBundleVersions.forEach { version ->
diff --git 
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/LanceRESTService.java
 
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/LanceRESTService.java
index 041e038198..98f86aaae5 100644
--- 
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/LanceRESTService.java
+++ 
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/LanceRESTService.java
@@ -21,6 +21,8 @@ package org.apache.gravitino.lance;
 import static 
org.apache.gravitino.lance.common.config.LanceConfig.NAMESPACE_BACKEND;
 
 import java.lang.reflect.Constructor;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Map;
 import javax.servlet.Servlet;
 import org.apache.gravitino.GravitinoEnv;
@@ -99,9 +101,17 @@ public class LanceRESTService implements 
GravitinoAuxiliaryService {
     server.addFilter(new RequestContextFilter(eventBus), LANCE_SPEC);
     server.addFilter(
         new HttpAuditFilter(eventBus, 
EventSource.GRAVITINO_LANCE_REST_SERVER), LANCE_SPEC);
-    server.addCustomFilters(LANCE_SPEC);
     server.addSystemFilters(LANCE_SPEC);
 
+    registerMetricsPathFilters(server, eventBus);
+
+    // Custom filters are registered once, across every filtered path in a 
single call, so a
+    // filter whose init() isn't safe to run more than once per JVM only runs 
it once rather than
+    // once per pathSpec.
+    List<String> customFilterPaths = new 
ArrayList<>(JettyServer.METRICS_PATH_SPECS);
+    customFilterPaths.add(LANCE_SPEC);
+    server.addCustomFilters(customFilterPaths.toArray(new String[0]));
+
     LOG.info(
         "Initialized Lance REST service for backend {} in {} mode",
         lanceConfig.getNamespaceBackend(),
@@ -133,6 +143,26 @@ public class LanceRESTService implements 
GravitinoAuxiliaryService {
     }
   }
 
+  /**
+   * Registers request-context tracking and audit-on-failure coverage on {@link
+   * JettyServer#METRICS_PATH_SPECS}. {@code /metrics} and {@code 
/prometheus/metrics} used to
+   * receive no such coverage at all, with nothing in the build catching it; 
{@code
+   * RequestContextFilter} is included too so query-parameter capture applies 
uniformly, matching
+   * {@link #LANCE_SPEC}. Package-private and static so a unit test can 
exercise it directly against
+   * a plain {@link JettyServer}, without booting the rest of {@link 
#serviceInit}. See GH-12760.
+   *
+   * @param server the Jetty server whose {@link 
JettyServer#METRICS_PATH_SPECS} need filter
+   *     coverage
+   * @param eventBus the event bus audit events are dispatched through
+   */
+  static void registerMetricsPathFilters(JettyServer server, EventBus 
eventBus) {
+    for (String pathSpec : JettyServer.METRICS_PATH_SPECS) {
+      server.addFilter(new RequestContextFilter(eventBus), pathSpec);
+      server.addFilter(
+          new HttpAuditFilter(eventBus, 
EventSource.GRAVITINO_LANCE_REST_SERVER), pathSpec);
+    }
+  }
+
   private NamespaceWrapper loadNamespaceImpl(LanceConfig lanceConfig) {
     String backendType = lanceConfig.get(NAMESPACE_BACKEND);
     LanceNamespaceBackend lanceNamespaceBackend = 
LanceNamespaceBackend.fromType(backendType);
diff --git 
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/TestLanceRESTService.java
 
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/TestLanceRESTService.java
new file mode 100644
index 0000000000..bda74859f0
--- /dev/null
+++ 
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/TestLanceRESTService.java
@@ -0,0 +1,77 @@
+/*
+ * 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.lance;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Collections;
+import java.util.Set;
+import org.apache.gravitino.lance.common.config.LanceConfig;
+import org.apache.gravitino.listener.EventBus;
+import org.apache.gravitino.server.web.HttpAuditFilter;
+import org.apache.gravitino.server.web.JettyServer;
+import org.apache.gravitino.server.web.JettyServerConfig;
+import org.apache.gravitino.server.web.JettyServerTestUtils;
+import org.apache.gravitino.server.web.RequestContextFilter;
+import org.eclipse.jetty.servlet.ServletHandler;
+import org.junit.jupiter.api.Test;
+
+public class TestLanceRESTService {
+
+  /**
+   * LanceRESTService.serviceInit() previously registered /metrics and 
/prometheus/metrics (added by
+   * JettyServer#initialize() itself, outside LANCE_SPEC) with no audit 
coverage at all, and nothing
+   * in the build caught it. Rather than parse source text, this exercises the 
extracted
+   * LanceRESTService#registerMetricsPathFilters against a plain JettyServer 
and inspects the real
+   * ServletHandler filter mappings it produces, so a broken 
JettyServer.METRICS_PATH_SPECS list or
+   * a filter that merely appears in a comment cannot pass. See GH-12760.
+   */
+  @Test
+  public void testMetricsPathsHaveAuditFilterCoverage() throws Exception {
+    JettyServer server = new JettyServer();
+    JettyServerConfig jettyServerConfig = JettyServerConfig.fromConfig(new 
LanceConfig());
+    server.initialize(jettyServerConfig, "test-lance-rest", false);
+    EventBus eventBus = new EventBus(Collections.emptyList());
+
+    try {
+      LanceRESTService.registerMetricsPathFilters(server, eventBus);
+
+      ServletHandler servletHandler =
+          
JettyServerTestUtils.getServletContextHandler(server).getServletHandler();
+      Set<String> auditedPathSpecs =
+          JettyServerTestUtils.filterPathSpecsFor(servletHandler, 
HttpAuditFilter.class);
+      Set<String> requestContextPathSpecs =
+          JettyServerTestUtils.filterPathSpecsFor(servletHandler, 
RequestContextFilter.class);
+
+      for (String pathSpec : JettyServer.METRICS_PATH_SPECS) {
+        assertTrue(
+            auditedPathSpecs.contains(pathSpec),
+            "'" + pathSpec + "' must be covered by HttpAuditFilter, see 
GH-12760");
+        assertTrue(
+            requestContextPathSpecs.contains(pathSpec),
+            "'"
+                + pathSpec
+                + "' must be covered by RequestContextFilter for 
query-parameter "
+                + "capture, see GH-12760");
+      }
+    } finally {
+      server.stop();
+    }
+  }
+}
diff --git a/server-common/build.gradle.kts b/server-common/build.gradle.kts
index fdcdf2f931..aa48f71060 100644
--- a/server-common/build.gradle.kts
+++ b/server-common/build.gradle.kts
@@ -72,3 +72,16 @@ tasks {
     environment("GRAVITINO_TEST", "true")
   }
 }
+
+val testJar by tasks.registering(Jar::class) {
+  archiveClassifier.set("tests")
+  from(sourceSets["test"].output)
+}
+
+configurations {
+  create("testArtifacts")
+}
+
+artifacts {
+  add("testArtifacts", testJar)
+}
diff --git 
a/server-common/src/main/java/org/apache/gravitino/server/web/JettyServer.java 
b/server-common/src/main/java/org/apache/gravitino/server/web/JettyServer.java
index 260791a4ca..2385b49a1f 100644
--- 
a/server-common/src/main/java/org/apache/gravitino/server/web/JettyServer.java
+++ 
b/server-common/src/main/java/org/apache/gravitino/server/web/JettyServer.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.server.web;
 import com.codahale.metrics.MetricRegistry;
 import com.codahale.metrics.servlets.MetricsServlet;
 import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
 import java.io.File;
 import java.io.IOException;
 import java.net.BindException;
@@ -71,6 +72,16 @@ public class JettyServer {
   private static final String HTTPS = "https";
   private static final String HTTP_PROTOCOL = "http/1.1";
 
+  /**
+   * The pathSpecs {@link #initialize} registers directly on the shared 
servlet context when a
+   * {@link MetricsSystem} is available, outside of whatever pathspec the 
caller itself filters.
+   * Callers that need request-context tracking or audit coverage on these 
paths (see GH-12760) must
+   * apply their own filters to them explicitly, using this constant rather 
than re-declaring the
+   * literal path strings, so the two can never drift apart.
+   */
+  public static final ImmutableList<String> METRICS_PATH_SPECS =
+      ImmutableList.of("/metrics", "/prometheus/metrics");
+
   private Server server;
 
   private ServletContextHandler servletContextHandler;
@@ -174,10 +185,10 @@ public class JettyServer {
       MetricRegistry metricRegistry = metricsSystem.getMetricRegistry();
       servletContextHandler.setAttribute(
           "com.codahale.metrics.servlets.MetricsServlet.registry", 
metricRegistry);
-      servletContextHandler.addServlet(MetricsServlet.class, "/metrics");
+      servletContextHandler.addServlet(MetricsServlet.class, 
METRICS_PATH_SPECS.get(0));
 
       servletContextHandler.addServlet(
-          new ServletHolder(metricsSystem.getPrometheusServlet()), 
"/prometheus/metrics");
+          new ServletHolder(metricsSystem.getPrometheusServlet()), 
METRICS_PATH_SPECS.get(1));
     }
 
     HandlerCollection handlers = new HandlerCollection();
@@ -493,7 +504,14 @@ public class JettyServer {
     return server.getThreadPool();
   }
 
-  public void addCustomFilters(String pathSpec) {
+  /**
+   * Registers every configured custom filter, binding each one's single 
{@link FilterHolder} to all
+   * of {@code pathSpecs} in one pass — so a filter whose {@code init()} isn't 
safe to run more than
+   * once per JVM only runs it once, regardless of how many paths it's bound 
to.
+   *
+   * @param pathSpecs the pathSpecs to bind each configured custom filter to
+   */
+  public void addCustomFilters(String... pathSpecs) {
     for (String filterName : serverConfig.getCustomFilters()) {
       if (StringUtils.isBlank(filterName)) {
         continue;
@@ -504,7 +522,10 @@ public class JettyServer {
           serverConfig.getAllWithPrefix(String.format("%s.param.", 
filterName)).entrySet()) {
         filterHolder.setInitParameter(entry.getKey(), entry.getValue());
       }
-      servletContextHandler.addFilter(filterHolder, pathSpec, 
EnumSet.allOf(DispatcherType.class));
+      for (String pathSpec : pathSpecs) {
+        servletContextHandler.addFilter(
+            filterHolder, pathSpec, EnumSet.allOf(DispatcherType.class));
+      }
     }
   }
 
diff --git 
a/server-common/src/test/java/org/apache/gravitino/server/web/JettyServerTestUtils.java
 
b/server-common/src/test/java/org/apache/gravitino/server/web/JettyServerTestUtils.java
new file mode 100644
index 0000000000..d9706f6f7e
--- /dev/null
+++ 
b/server-common/src/test/java/org/apache/gravitino/server/web/JettyServerTestUtils.java
@@ -0,0 +1,73 @@
+/*
+ * 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;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.Set;
+import java.util.stream.Collectors;
+import javax.servlet.Filter;
+import org.eclipse.jetty.servlet.ServletContextHandler;
+import org.eclipse.jetty.servlet.ServletHandler;
+
+/**
+ * Shared reflection/introspection helpers for tests that need to inspect a 
live {@link
+ * JettyServer}'s real servlet and filter mappings, rather than parsing source 
text or mocking
+ * Jetty. Published via server-common's {@code testArtifacts} configuration so 
tests in other
+ * modules built on {@link JettyServer} (the main server, Iceberg REST, Lance 
REST) don't each
+ * re-implement the same reflection and stream logic.
+ */
+public final class JettyServerTestUtils {
+
+  private JettyServerTestUtils() {}
+
+  /**
+   * Reflects into {@code server}'s private {@code servletContextHandler} 
field, which is otherwise
+   * only populated after {@link JettyServer#initialize} runs and has no 
public getter.
+   *
+   * @param server the Jetty server to inspect
+   * @return the server's live servlet context handler
+   * @throws ReflectiveOperationException if the field cannot be accessed
+   */
+  public static ServletContextHandler getServletContextHandler(JettyServer 
server)
+      throws ReflectiveOperationException {
+    Field handlerField = 
JettyServer.class.getDeclaredField("servletContextHandler");
+    handlerField.setAccessible(true);
+    return (ServletContextHandler) handlerField.get(server);
+  }
+
+  /**
+   * Returns every pathSpec that {@code filterClass} is bound to on {@code 
servletHandler}.
+   *
+   * @param servletHandler the live servlet handler to inspect
+   * @param filterClass the filter class to look for
+   * @return the set of pathSpecs the filter is registered on
+   */
+  public static Set<String> filterPathSpecsFor(
+      ServletHandler servletHandler, Class<? extends Filter> filterClass) {
+    return Arrays.stream(servletHandler.getFilterMappings())
+        .filter(
+            filterMapping ->
+                filterClass
+                    .getName()
+                    
.equals(servletHandler.getFilter(filterMapping.getFilterName()).getClassName()))
+        .flatMap(filterMapping -> Arrays.stream(filterMapping.getPathSpecs()))
+        .collect(Collectors.toSet());
+  }
+}
diff --git a/server/build.gradle.kts b/server/build.gradle.kts
index c5627f62b1..895c64a511 100644
--- a/server/build.gradle.kts
+++ b/server/build.gradle.kts
@@ -54,6 +54,7 @@ dependencies {
   testCompileOnly(libs.lombok)
 
   testImplementation(libs.commons.io)
+  testImplementation(project(":server-common", "testArtifacts"))
   testImplementation(libs.jersey.test.framework.core) {
     exclude(group = "org.junit.jupiter")
   }
diff --git 
a/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java 
b/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
index 0f83f8d482..f2ef1e77a8 100644
--- a/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
+++ b/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
@@ -18,9 +18,12 @@
  */
 package org.apache.gravitino.server;
 
+import com.google.common.collect.ImmutableList;
 import java.io.File;
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Properties;
 import java.util.concurrent.atomic.AtomicBoolean;
 import javax.inject.Singleton;
@@ -83,6 +86,18 @@ public class GravitinoServer extends ResourceConfig {
 
   private static final String API_ANY_PATH = "/api/*";
 
+  // Servlets mounted outside API_ANY_PATH that still need request-context 
tracking and
+  // audit-on-failure coverage. VersioningFilter is intentionally not applied 
to these: it
+  // negotiates Gravitino's "application/vnd.gravitino.vN+json" media type for 
the Jersey-managed
+  // REST surface, and none of these servlets are part of it or read that 
header. /metrics and
+  // /prometheus/metrics are registered by JettyServer#initialize() itself 
(see server-common's
+  // JettyServer), outside GravitinoServer's own control entirely. See 
GH-12760.
+  private static final ImmutableList<String> ROOT_MOUNTED_PATHS =
+      ImmutableList.<String>builder()
+          .add("/configs", "/configs/secrets/providers")
+          .addAll(JettyServer.METRICS_PATH_SPECS)
+          .build();
+
   public static final String CONF_FILE = "gravitino.conf";
 
   public static final String WEBSERVER_CONF_PREFIX = 
"gravitino.server.webserver.";
@@ -190,15 +205,45 @@ public class GravitinoServer extends ResourceConfig {
 
     // Root-level aliases for enterprise GTMs that require probes at 
well-known root paths.
     // Forwards /health, /health/live, /health/ready, and /health.html to the 
canonical
-    // /api/health/* endpoints.
+    // /api/health/* endpoints. Not part of ROOT_MOUNTED_PATHS below: 
HealthAliasServlet forwards
+    // every request into /api/health*, which API_ANY_PATH already covers via 
the servlet
+    // container's FORWARD dispatcher type, so binding the filters again here 
would double-log
+    // every probe.
     server.addServlet(new HealthAliasServlet(), "/health/*");
     server.addServlet(new HealthAliasServlet(), "/health.html");
 
+    // API_ANY_PATH keeps its original, unabridged filter set: this is the 
only pathspec that is
+    // part of the Jersey-managed REST surface, so it's the only one 
VersioningFilter applies to.
     server.addFilter(new RequestContextFilter(gravitinoEnv.eventBus()), 
API_ANY_PATH);
     server.addFilter(
         new HttpAuditFilter(gravitinoEnv.eventBus(), 
EventSource.GRAVITINO_SERVER), API_ANY_PATH);
-    server.addCustomFilters(API_ANY_PATH);
     server.addFilter(new VersioningFilter(), API_ANY_PATH);
+
+    // GH-12760: servlets mounted outside API_ANY_PATH used to receive none of 
the filters below
+    // (no request-context tracking, no audit-on-failure, no custom filters), 
with nothing in the
+    // build catching it. Every pathSpec a servlet is registered under above 
must appear in
+    // ROOT_MOUNTED_PATHS, unless it forwards into an already-covered path 
(see HealthAliasServlet
+    // above) or is added to the exemption list documented on
+    // TestGravitinoServer#testEveryServletPathIsCoveredByAuditFilter. 
RequestContextFilter is
+    // given gravitinoEnv.eventBus() here too (not just API_ANY_PATH) so 
query-parameter capture
+    // (see RequestContextFilter's class doc) applies uniformly, not only to 
the REST API.
+    for (String pathSpec : ROOT_MOUNTED_PATHS) {
+      server.addFilter(new RequestContextFilter(gravitinoEnv.eventBus()), 
pathSpec);
+      server.addFilter(
+          new HttpAuditFilter(gravitinoEnv.eventBus(), 
EventSource.GRAVITINO_SERVER), pathSpec);
+    }
+
+    // Custom filters are registered once, across every filtered path in a 
single call, so a
+    // filter whose init() isn't safe to run more than once per JVM only runs 
it once rather than
+    // once per pathSpec.
+    List<String> customFilterPaths = new ArrayList<>(ROOT_MOUNTED_PATHS);
+    customFilterPaths.add(API_ANY_PATH);
+    server.addCustomFilters(customFilterPaths.toArray(new String[0]));
+
+    // Only API_ANY_PATH requires authentication today. /configs must stay 
open for the Web UI's
+    // pre-login OAuth bootstrap (see docs/gravitino-server-config.md); 
/configs/secrets/providers
+    // is open pending GH-12921, which will add an operator-controlled 
authorization gate for it
+    // specifically.
     server.addSystemFilters(API_ANY_PATH);
     if (server.isWebUiEnabled()) {
       server.addFilter(new WebUIFilter(), "/"); // Redirect to the /ui/index 
html page.
diff --git 
a/server/src/test/java/org/apache/gravitino/server/TestGravitinoServer.java 
b/server/src/test/java/org/apache/gravitino/server/TestGravitinoServer.java
index 0c5cfa48e6..d9d2408731 100644
--- a/server/src/test/java/org/apache/gravitino/server/TestGravitinoServer.java
+++ b/server/src/test/java/org/apache/gravitino/server/TestGravitinoServer.java
@@ -23,15 +23,26 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import java.io.IOException;
+import java.lang.reflect.Field;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.util.Set;
 import org.apache.commons.io.FileUtils;
 import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.auxiliary.AuxiliaryServiceManager;
 import org.apache.gravitino.rest.RESTUtils;
+import org.apache.gravitino.server.authentication.AuthenticationFilter;
+import org.apache.gravitino.server.web.HttpAuditFilter;
+import org.apache.gravitino.server.web.JettyServer;
 import org.apache.gravitino.server.web.JettyServerConfig;
+import org.apache.gravitino.server.web.JettyServerTestUtils;
+import org.eclipse.jetty.http.pathmap.ServletPathSpec;
+import org.eclipse.jetty.servlet.ServletContextHandler;
+import org.eclipse.jetty.servlet.ServletHandler;
+import org.eclipse.jetty.servlet.ServletMapping;
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeAll;
@@ -44,6 +55,37 @@ import org.mockito.Mockito;
 @TestInstance(Lifecycle.PER_CLASS)
 public class TestGravitinoServer {
 
+  // Static-asset and forwarding paths shared by both exemption sets below, 
each for the same
+  // reason in both: WebUIFilter's static assets and HealthAliasServlet's 
forwarded probes need
+  // no direct filter binding of their own on this path. See GH-12760.
+  private static final Set<String> STATIC_AND_FORWARDING_PATHS =
+      ImmutableSet.of(
+          "/", // DefaultServlet / WebUIFilter: serves static UI assets, no 
server-side logic.
+          "/ui/*", // WebUIFilter: serves static UI assets, no server-side 
logic.
+          // HealthAliasServlet forwards both of the next two into 
/api/health*, which is already
+          // covered via the servlet container's FORWARD dispatcher type.
+          "/health/*",
+          "/health.html");
+
+  // Paths that legitimately have no HttpAuditFilter binding of their own. 
GH-12760: extending
+  // this set is a deliberate, reviewed decision, not a default — everything 
else registered on
+  // the servlet context must be covered.
+  private static final Set<String> PATHS_EXEMPT_FROM_DIRECT_AUDIT_COVERAGE =
+      STATIC_AND_FORWARDING_PATHS;
+
+  // Paths that are deliberately reachable without authentication. GH-12760: 
extending this set
+  // is a deliberate, reviewed decision, not a default — every other servlet 
path must be covered
+  // by AuthenticationFilter. This is a distinct invariant from
+  // PATHS_EXEMPT_FROM_DIRECT_AUDIT_COVERAGE above: e.g. /configs is fully 
audited but
+  // intentionally unauthenticated, so it belongs in this set but not that one.
+  private static final Set<String> KNOWN_PUBLIC_PATHS =
+      ImmutableSet.<String>builder()
+          .addAll(STATIC_AND_FORWARDING_PATHS)
+          .add("/configs") // Intentionally public: backs the Web UI's 
pre-login OAuth bootstrap.
+          .add("/configs/secrets/providers") // Open pending GH-12921 (tracked 
authz gate).
+          .addAll(JettyServer.METRICS_PATH_SPECS) // Conventionally scraped 
without credentials.
+          .build();
+
   private GravitinoServer gravitinoServer;
   private ServerConfig spyServerConfig;
 
@@ -136,4 +178,85 @@ public class TestGravitinoServer {
         hookBlock.contains("server.gracefulStop()"),
         "Shutdown hook should invoke server.gracefulStop() so app-level 
cleanup runs on SIGTERM");
   }
+
+  @Test
+  public void testEveryServletPathIsCoveredByAuditFilter() throws Exception {
+    gravitinoServer.initialize();
+
+    ServletHandler servletHandler = 
getServletContextHandler(gravitinoServer).getServletHandler();
+    Set<String> auditedPathSpecs =
+        JettyServerTestUtils.filterPathSpecsFor(servletHandler, 
HttpAuditFilter.class);
+
+    for (ServletMapping servletMapping : servletHandler.getServletMappings()) {
+      for (String pathSpec : servletMapping.getPathSpecs()) {
+        if (PATHS_EXEMPT_FROM_DIRECT_AUDIT_COVERAGE.contains(pathSpec)) {
+          continue;
+        }
+        assertTrue(
+            isPathSpecCovered(pathSpec, auditedPathSpecs),
+            "Servlet path '"
+                + pathSpec
+                + "' is registered without HttpAuditFilter coverage. See 
GH-12760: every "
+                + "servlet mounted outside /api/* must be wired into 
GravitinoServer's "
+                + "ROOT_MOUNTED_PATHS filter loop, or added to "
+                + "PATHS_EXEMPT_FROM_DIRECT_AUDIT_COVERAGE above with a 
documented reason.");
+      }
+    }
+  }
+
+  @Test
+  public void testEveryServletPathIsEitherAuthenticatedOrDeliberatelyPublic() 
throws Exception {
+    gravitinoServer.initialize();
+
+    ServletHandler servletHandler = 
getServletContextHandler(gravitinoServer).getServletHandler();
+    Set<String> authenticatedPathSpecs =
+        JettyServerTestUtils.filterPathSpecsFor(servletHandler, 
AuthenticationFilter.class);
+
+    for (ServletMapping servletMapping : servletHandler.getServletMappings()) {
+      for (String pathSpec : servletMapping.getPathSpecs()) {
+        if (isPathSpecCovered(pathSpec, authenticatedPathSpecs)) {
+          continue;
+        }
+        assertTrue(
+            KNOWN_PUBLIC_PATHS.contains(pathSpec),
+            "Servlet path '"
+                + pathSpec
+                + "' is neither covered by AuthenticationFilter nor listed in "
+                + "KNOWN_PUBLIC_PATHS. See GH-12760: a servlet must either 
require "
+                + "authentication or be a deliberate, reviewed public 
exception.");
+      }
+    }
+  }
+
+  /**
+   * Whether every request a servlet registered under {@code servletPathSpec} 
can receive is also
+   * matched by at least one of {@code filterPathSpecs}, using Jetty's own 
path-spec matching
+   * semantics rather than exact string equality — so a servlet mounted at, 
say, {@code
+   * /api/internal/*} is correctly recognized as already covered by a filter 
bound to {@code
+   * /api/*}.
+   *
+   * @param servletPathSpec the servlet's registered pathSpec
+   * @param filterPathSpecs the pathSpecs a filter is bound to
+   * @return true if {@code servletPathSpec} is covered by one of {@code 
filterPathSpecs}
+   */
+  private static boolean isPathSpecCovered(String servletPathSpec, Set<String> 
filterPathSpecs) {
+    // A path-prefix spec like "/api/*" matches any concrete path under it; 
substitute a
+    // representative concrete path so ServletPathSpec#matches can evaluate a 
real request path
+    // instead of a pattern.
+    String representativePath =
+        servletPathSpec.endsWith("/*")
+            ? servletPathSpec.substring(0, servletPathSpec.length() - 1) + 
"probe"
+            : servletPathSpec;
+    return filterPathSpecs.stream()
+        .anyMatch(
+            filterPathSpec -> new 
ServletPathSpec(filterPathSpec).matches(representativePath));
+  }
+
+  private static ServletContextHandler 
getServletContextHandler(GravitinoServer gravitinoServer)
+      throws Exception {
+    Field serverField = GravitinoServer.class.getDeclaredField("server");
+    serverField.setAccessible(true);
+    JettyServer jettyServer = (JettyServer) serverField.get(gravitinoServer);
+    return JettyServerTestUtils.getServletContextHandler(jettyServer);
+  }
 }

Reply via email to