From 84be562ba07550d0fc387d70217deb62d460a2ed Mon Sep 17 00:00:00 2001 From: Petr Fedchenkov Date: Wed, 8 Jul 2026 15:34:19 +0300 Subject: [PATCH 1/2] NGSOK-1852 Add MetricsAuthFilter to path-scope auth on the Spark UI Spark's HTTP security is UI-wide: spark.ui.filters, the ACLs and the TLS connector all apply to every path served by the Spark UI, so there is no built-in way to require a bearer token on /metrics/* while leaving the human UI on Kerberos. A Prometheus/vmagent scraper cannot perform SPNEGO, so a UI-wide SPNEGO filter would block scraping outright. MetricsAuthFilter is a single spark.ui.filters filter that dispatches by path: bearer-guarded (or open) for /metrics/*, and an optional delegate to Hadoop's javax AuthenticationFilter (SPNEGO/pseudo) for the rest of the UI. Spark 3.5's UI is javax.servlet, so Hadoop's own AuthenticationFilter plugs in directly - no Spark wrapper is needed, unlike Spark 4 whose jakarta UI requires one. The delegate is loaded reflectively so this compiles against the shaded hadoop-client-api while the unshaded hadoop-auth supplies the real filter at runtime. Ships in spark-core, configured via spark.org.apache.spark.filter.MetricsAuthFilter.param.*. Both this filter and using Hadoop's AuthenticationFilter directly are documented in docs/security.md. --- .../spark/filter/MetricsAuthFilter.java | 254 +++++++++++++++ .../spark/filter/MetricsAuthFilterSuite.java | 295 ++++++++++++++++++ docs/security.md | 45 ++- 3 files changed, 592 insertions(+), 2 deletions(-) create mode 100644 core/src/main/java/org/apache/spark/filter/MetricsAuthFilter.java create mode 100644 core/src/test/java/org/apache/spark/filter/MetricsAuthFilterSuite.java diff --git a/core/src/main/java/org/apache/spark/filter/MetricsAuthFilter.java b/core/src/main/java/org/apache/spark/filter/MetricsAuthFilter.java new file mode 100644 index 0000000000000..013ff3c3b0176 --- /dev/null +++ b/core/src/main/java/org/apache/spark/filter/MetricsAuthFilter.java @@ -0,0 +1,254 @@ +/* + * 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.spark.filter; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.Collections; +import java.util.List; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletContext; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * {@code spark.ui.filters} filter that applies DIFFERENT policies to the + * Spark Prometheus metrics endpoints ({@code /metrics/*}) and to the rest of the + * Spark UI - something Spark config alone cannot do (filters, ACLs and the TLS + * connector are all UI-wide). It supports three independent, composable pieces: + * + *
    + *
  1. Metrics auth - {@code /metrics/*} is guarded by a shared bearer + * token. If the {@code token} param is OMITTED, metrics are left OPEN (the + * "no token" mode) - useful when the scraper cannot present credentials but + * the rest of the UI still must be protected.
  2. + *
  3. UI auth via SPNEGO/pseudo - when the {@code spnego.type} param is + * present, every NON-metrics path is delegated to Hadoop's + * {@code org.apache.hadoop.security.authentication.server.AuthenticationFilter} + * ({@code type=kerberos} for SPNEGO, {@code simple} for pseudo) - a real + * {@code javax.servlet.Filter} on this Spark 3.5 (javax) build. The metrics + * path NEVER hits SPNEGO, so vmagent/Prometheus (which cannot do Kerberos) + * can still scrape.
  4. + *
  5. Fall-through - with neither param set for a given path, the + * request passes to Spark's own filters/ACLs unchanged.
  6. + *
+ * + *

WHY delegate instead of just listing both filters in {@code spark.ui.filters}: + * every filter in that list runs on every request, so a separately-listed + * {@code AuthenticationFilter} would still challenge {@code /metrics/*} and block + * the scraper. Embedding it and dispatching by path is the only way to exempt + * metrics. Register ONLY this filter in {@code spark.ui.filters}, not + * {@code AuthenticationFilter} as well. + * + *

If you do NOT need path-scoping (i.e. you are happy protecting the WHOLE UI, + * metrics included), skip this class and list Hadoop's + * {@code org.apache.hadoop.security.authentication.server.AuthenticationFilter} + * (SPNEGO) in {@code spark.ui.filters} directly - on Spark 3.5 it is a real + * {@code javax.servlet.Filter} and needs no wrapper. + * + *

Modes (combinations of the two params)

+ *
+ *   token set,   no spnego  -> metrics bearer-guarded; UI untouched (Spark ACLs)
+ *   token unset, no spnego  -> metrics OPEN;           UI untouched
+ *   token set,   spnego set -> metrics bearer-guarded; UI SPNEGO
+ *   token unset, spnego set -> metrics OPEN;           UI SPNEGO   (common case:
+ *                              open scrape, Kerberos for humans)
+ * 
+ * + *

Wire-up (driver / Spark Connect, port 4040)

+ * This class ships in {@code spark-core}, so it is already on the driver classpath - + * no extra jar is needed. Register it and configure its params via + * {@code spark..param.}: + *
+ * --conf spark.ui.filters=org.apache.spark.filter.MetricsAuthFilter
+ * # metrics token - OMIT this line for open metrics:
+ * --conf spark.org.apache.spark.filter.MetricsAuthFilter.param.token=<shared-secret>
+ * # optional SPNEGO for the rest of the UI (params carry a 'spnego.' prefix):
+ * --conf spark.org.apache.spark.filter.MetricsAuthFilter.param.spnego.type=kerberos
+ * --conf spark.org.apache.spark.filter.MetricsAuthFilter.param.spnego.kerberos.principal=HTTP/_HOST@REALM
+ * --conf spark.org.apache.spark.filter.MetricsAuthFilter.param.spnego.kerberos.keytab=/etc/keytabs/spnego.keytab
+ * # confidentiality: also enable TLS so the token is not sent in cleartext
+ * --conf spark.ssl.ui.enabled=true
+ * 
+ * On the History Server the registration is identical, but TLS is + * {@code spark.ssl.historyServer.*} instead of {@code spark.ssl.ui.*}. + * + *

Scraper side (vmagent / Prometheus)

+ *
+ * authorization:            # only when a token is configured; drop it for open metrics
+ *   type: Bearer
+ *   credentials_file: /etc/scrape-auth/token
+ * 
+ * + *

NOTE: authentication only - not encryption. Pair with TLS so the bearer + * token and the metrics travel encrypted. + */ +public class MetricsAuthFilter implements Filter { + + /** Path segment that identifies the Spark metrics endpoints. */ + private static final String METRICS_PREFIX = "/metrics/"; + + /** Init-param prefix carrying the embedded AuthenticationFilter's config. */ + private static final String SPNEGO_PREFIX = "spnego."; + + /** null => metrics are OPEN (no token required). */ + private byte[] expectedToken; + + /** null => non-metrics paths fall through untouched (no SPNEGO). */ + private Filter uiDelegate; + + @Override + public void init(FilterConfig cfg) throws ServletException { + // Fed by spark..param.token - exactly what + // JettyUtils.addFilters reads via conf.getAllWithPrefix("spark.$filter.param."). + // Absent/empty token => open metrics. + String token = cfg.getInitParameter("token"); + this.expectedToken = (token == null || token.isEmpty()) + ? null + : token.getBytes(StandardCharsets.UTF_8); + + // Optionally protect the rest of the UI with Hadoop's SPNEGO/pseudo + // filter. Enabled by presence of a 'spnego.type' param. + if (cfg.getInitParameter(SPNEGO_PREFIX + "type") != null) { + Filter delegate = createUiDelegate(); + delegate.init(new PrefixedFilterConfig(cfg, SPNEGO_PREFIX)); + this.uiDelegate = delegate; + } + } + + /** + * The filter that non-metrics UI paths are delegated to. On this + * (Spark 3.5.x, javax.servlet) build it is Hadoop's own + * {@code org.apache.hadoop.security.authentication.server.AuthenticationFilter}, + * a real {@code javax.servlet.Filter}. It is loaded reflectively so this class + * compiles against the shaded {@code hadoop-client-api} on the build classpath + * (whose AuthenticationFilter implements a relocated servlet API); at runtime + * the unshaded {@code hadoop-auth} supplies the concrete filter. Spark 4 needs a + * jakarta wrapper ({@code org.apache.spark.filter.AuthenticationFilter}) here + * because its UI is jakarta.servlet; Spark 3.5 does not. + * Package-private and overridable so tests can inject a stub without Kerberos. + */ + Filter createUiDelegate() { + try { + return (Filter) Class.forName( + "org.apache.hadoop.security.authentication.server.AuthenticationFilter") + .getDeclaredConstructor().newInstance(); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException( + "Hadoop AuthenticationFilter is not on the classpath", e); + } + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest req = (HttpServletRequest) request; + HttpServletResponse resp = (HttpServletResponse) response; + + String uri = req.getRequestURI(); + if (uri != null && uri.contains(METRICS_PREFIX)) { + // Metrics path: bearer token OR open (no-token mode). Never SPNEGO, so the + // scraper is not challenged with Kerberos. + if (expectedToken != null && !bearerMatches(req)) { + resp.setHeader("WWW-Authenticate", "Bearer"); + resp.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid or missing bearer token"); + return; // short-circuit + } + chain.doFilter(request, response); // straight to the metrics servlet + return; + } + + // Non-metrics UI path: SPNEGO if configured, else fall through to Spark. + if (uiDelegate != null) { + uiDelegate.doFilter(request, response, chain); // authenticates, then continues chain + } else { + chain.doFilter(request, response); + } + } + + private boolean bearerMatches(HttpServletRequest req) { + String auth = req.getHeader("Authorization"); + if (auth == null || !auth.startsWith("Bearer ")) { + return false; + } + // Constant-time compare so the token cannot be recovered via response timing. + byte[] presented = auth.substring(7).getBytes(StandardCharsets.UTF_8); + return MessageDigest.isEqual(presented, expectedToken); + } + + @Override + public void destroy() { + if (uiDelegate != null) { + uiDelegate.destroy(); + uiDelegate = null; + } + } + + /** + * A {@link FilterConfig} view that exposes only the init params starting with a + * given prefix, with the prefix stripped - so the embedded + * {@link AuthenticationFilter} sees {@code type}, {@code kerberos.principal}, + * ... rather than {@code spnego.type}, {@code spnego.kerberos.principal}, and + * never sees our own {@code token}. + */ + private static final class PrefixedFilterConfig implements FilterConfig { + private final FilterConfig delegate; + private final String prefix; + + PrefixedFilterConfig(FilterConfig delegate, String prefix) { + this.delegate = delegate; + this.prefix = prefix; + } + + @Override + public String getFilterName() { + return delegate.getFilterName(); + } + + @Override + public ServletContext getServletContext() { + return delegate.getServletContext(); + } + + @Override + public String getInitParameter(String name) { + return delegate.getInitParameter(prefix + name); + } + + @Override + public Enumeration getInitParameterNames() { + List stripped = new ArrayList<>(); + Enumeration all = delegate.getInitParameterNames(); + while (all.hasMoreElements()) { + String name = all.nextElement(); + if (name.startsWith(prefix)) { + stripped.add(name.substring(prefix.length())); + } + } + return Collections.enumeration(stripped); + } + } +} diff --git a/core/src/test/java/org/apache/spark/filter/MetricsAuthFilterSuite.java b/core/src/test/java/org/apache/spark/filter/MetricsAuthFilterSuite.java new file mode 100644 index 0000000000000..5b0f518621007 --- /dev/null +++ b/core/src/test/java/org/apache/spark/filter/MetricsAuthFilterSuite.java @@ -0,0 +1,295 @@ +/* + * 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.spark.filter; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.util.Collections; +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletContext; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.junit.Test; + +/** + * Unit tests for {@link MetricsAuthFilter}. The SPNEGO delegate is replaced + * with a {@link RecordingFilter} via the package-private {@code createUiDelegate()} + * seam, so no Kerberos/Hadoop machinery is needed. + */ +public class MetricsAuthFilterSuite { + + private static final String TOKEN = "s3cr3t-shared-token"; + + // ---- /metrics/* : bearer / open ----------------------------------------- + + @Test + public void metricsWithCorrectBearerPassesThrough() throws Exception { + RecordingFilter delegate = new RecordingFilter(); + MetricsAuthFilter f = newFilter(params("token", TOKEN), delegate); + + HttpServletRequest req = metricsRequest("Bearer " + TOKEN); + HttpServletResponse resp = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + + f.doFilter(req, resp, chain); + + verify(chain, times(1)).doFilter(req, resp); + verify(resp, never()).sendError(org.mockito.ArgumentMatchers.anyInt(), + org.mockito.ArgumentMatchers.anyString()); + assertEquals("metrics path must never hit the UI delegate", 0, delegate.doFilterCalls); + } + + @Test + public void metricsWithWrongTokenIsRejected() throws Exception { + MetricsAuthFilter f = newFilter(params("token", TOKEN), new RecordingFilter()); + + HttpServletRequest req = metricsRequest("Bearer not-the-token"); + HttpServletResponse resp = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + + f.doFilter(req, resp, chain); + + verify(resp).setHeader("WWW-Authenticate", "Bearer"); + verify(resp).sendError(HttpServletResponse.SC_UNAUTHORIZED, + "Invalid or missing bearer token"); + verify(chain, never()).doFilter(req, resp); + } + + @Test + public void metricsWithMissingHeaderIsRejected() throws Exception { + MetricsAuthFilter f = newFilter(params("token", TOKEN), new RecordingFilter()); + + HttpServletRequest req = metricsRequest(null); // no Authorization header + HttpServletResponse resp = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + + f.doFilter(req, resp, chain); + + verify(resp).sendError(HttpServletResponse.SC_UNAUTHORIZED, + "Invalid or missing bearer token"); + verify(chain, never()).doFilter(req, resp); + } + + @Test + public void metricsOpenWhenNoTokenConfigured() throws Exception { + MetricsAuthFilter f = newFilter(Collections.emptyMap(), new RecordingFilter()); + + HttpServletRequest req = metricsRequest(null); // no credentials at all + HttpServletResponse resp = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + + f.doFilter(req, resp, chain); + + verify(chain, times(1)).doFilter(req, resp); + verify(resp, never()).sendError(org.mockito.ArgumentMatchers.anyInt(), + org.mockito.ArgumentMatchers.anyString()); + } + + @Test + public void bearerOfDifferentLengthIsRejected() throws Exception { + // Guards the constant-time MessageDigest.isEqual path against length mismatch. + MetricsAuthFilter f = newFilter(params("token", TOKEN), new RecordingFilter()); + + HttpServletRequest req = metricsRequest("Bearer short"); + HttpServletResponse resp = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + + f.doFilter(req, resp, chain); + + verify(resp).sendError(HttpServletResponse.SC_UNAUTHORIZED, + "Invalid or missing bearer token"); + verify(chain, never()).doFilter(req, resp); + } + + // ---- non-metrics : fall-through vs SPNEGO delegate ---------------------- + + @Test + public void nonMetricsFallsThroughWhenNoSpnego() throws Exception { + RecordingFilter delegate = new RecordingFilter(); + MetricsAuthFilter f = newFilter(params("token", TOKEN), delegate); + + HttpServletRequest req = uiRequest("/jobs/"); + HttpServletResponse resp = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + + f.doFilter(req, resp, chain); + + verify(chain, times(1)).doFilter(req, resp); + assertEquals("delegate not created without spnego.type", 0, delegate.doFilterCalls); + } + + @Test + public void nonMetricsIsDelegatedWhenSpnegoConfigured() throws Exception { + RecordingFilter delegate = new RecordingFilter(); + Map p = params("token", TOKEN); + p.put("spnego.type", "simple"); + MetricsAuthFilter f = newFilter(p, delegate); + + HttpServletRequest req = uiRequest("/jobs/"); + HttpServletResponse resp = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + + f.doFilter(req, resp, chain); + + assertEquals("UI path must reach the SPNEGO delegate", 1, delegate.doFilterCalls); + verify(chain, times(1)).doFilter(req, resp); // RecordingFilter continues the chain + } + + @Test + public void metricsNeverReachesDelegateEvenWhenSpnegoConfigured() throws Exception { + RecordingFilter delegate = new RecordingFilter(); + Map p = params("token", TOKEN); + p.put("spnego.type", "simple"); + MetricsAuthFilter f = newFilter(p, delegate); + + HttpServletRequest req = metricsRequest("Bearer " + TOKEN); + HttpServletResponse resp = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + + f.doFilter(req, resp, chain); + + assertEquals("scraper must never be challenged with SPNEGO", 0, delegate.doFilterCalls); + verify(chain, times(1)).doFilter(req, resp); + } + + // ---- PrefixedFilterConfig : prefix stripping + token hiding -------------- + + @Test + public void delegateSeesStrippedParamsAndNotToken() throws Exception { + RecordingFilter delegate = new RecordingFilter(); + Map p = params("token", TOKEN); + p.put("spnego.type", "kerberos"); + p.put("spnego.kerberos.principal", "HTTP/_HOST@REALM"); + MetricsAuthFilter f = newFilter(p, delegate); + + FilterConfig seen = delegate.initConfig; + assertEquals("prefix must be stripped", "kerberos", seen.getInitParameter("type")); + assertEquals("HTTP/_HOST@REALM", seen.getInitParameter("kerberos.principal")); + assertNull("delegate must not see our own token", seen.getInitParameter("token")); + assertNull("prefixed name must not leak", seen.getInitParameter("spnego.type")); + + List names = Collections.list(seen.getInitParameterNames()); + assertTrue(names.contains("type")); + assertTrue(names.contains("kerberos.principal")); + assertFalse(names.contains("token")); + assertFalse(names.contains("spnego.type")); + } + + // ---- helpers ------------------------------------------------------------ + + private static MetricsAuthFilter newFilter(Map initParams, + Filter delegate) throws ServletException { + MetricsAuthFilter f = new MetricsAuthFilter() { + @Override + Filter createUiDelegate() { + return delegate; + } + }; + f.init(new MapFilterConfig(initParams)); + return f; + } + + private static HttpServletRequest metricsRequest(String authorization) { + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getRequestURI()).thenReturn("/metrics/prometheus/"); + when(req.getHeader("Authorization")).thenReturn(authorization); + return req; + } + + private static HttpServletRequest uiRequest(String uri) { + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getRequestURI()).thenReturn(uri); + return req; + } + + private static Map params(String k, String v) { + Map m = new LinkedHashMap<>(); + m.put(k, v); + return m; + } + + /** A {@link Filter} that records how it was init'd and continues the chain. */ + private static final class RecordingFilter implements Filter { + private FilterConfig initConfig; + private int doFilterCalls; + + @Override + public void init(FilterConfig filterConfig) { + this.initConfig = filterConfig; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + doFilterCalls++; + chain.doFilter(request, response); + } + + @Override + public void destroy() { + } + } + + /** Minimal {@link FilterConfig} backed by a map, for driving {@code init}. */ + private static final class MapFilterConfig implements FilterConfig { + private final Map params; + + MapFilterConfig(Map params) { + this.params = params; + } + + @Override + public String getFilterName() { + return "MetricsAuthFilter"; + } + + @Override + public ServletContext getServletContext() { + return null; + } + + @Override + public String getInitParameter(String name) { + return params.get(name); + } + + @Override + public Enumeration getInitParameterNames() { + return Collections.enumeration(params.keySet()); + } + } +} diff --git a/docs/security.md b/docs/security.md index e6ef9ea584a1b..e8e6951cab764 100644 --- a/docs/security.md +++ b/docs/security.md @@ -287,8 +287,9 @@ The following settings cover enabling encryption for data written to disk: ## Authentication and Authorization Enabling authentication for the Web UIs is done using [javax servlet filters](https://docs.oracle.com/javaee/6/api/javax/servlet/Filter.html). -You will need a filter that implements the authentication method you want to deploy. Spark does not -provide any built-in authentication filters. +You will need a filter that implements the authentication method you want to deploy. Apache Spark +does not provide any built-in authentication filters, but this distribution ships the two described +in [Built-in authentication filters](#built-in-authentication-filters) below. Spark also supports access control to the UI when an authentication filter is present. Each application can be configured with its own separate access control lists (ACLs). Spark @@ -405,6 +406,46 @@ The following options control the authentication of Web UIs: On YARN, the view and modify ACLs are provided to the YARN service when submitting applications, and control who has the respective privileges via YARN interfaces. +### Built-in authentication filters + +Apache Spark ships no Web UI authentication filter, but Hadoop's +`org.apache.hadoop.security.authentication.server.AuthenticationFilter` is a `javax.servlet.Filter` +that plugs directly into `spark.ui.filters` and protects the **whole** Web UI with SPNEGO/Kerberos +(`type=kerberos`) or pseudo (`type=simple`) authentication. Filter parameters are passed as +`spark..param.` (the convention Spark uses for any UI filter): + +``` +spark.ui.filters=org.apache.hadoop.security.authentication.server.AuthenticationFilter +spark.org.apache.hadoop.security.authentication.server.AuthenticationFilter.param.type=kerberos +spark.org.apache.hadoop.security.authentication.server.AuthenticationFilter.param.kerberos.principal=HTTP/_HOST@REALM +spark.org.apache.hadoop.security.authentication.server.AuthenticationFilter.param.kerberos.keytab=/etc/security/keytabs/spnego.keytab +``` + +This distribution additionally ships `org.apache.spark.filter.MetricsAuthFilter` in `spark-core` +(no extra jar required). Both authenticate only - pair them with UI TLS (`spark.ssl.ui.enabled=true`, +or `spark.ssl.historyServer.*` on the History Server) so credentials are not sent in the clear. + +`MetricsAuthFilter` applies **different** policies to the Prometheus metrics +endpoints (`/metrics/*`) and to the rest of the UI - something plain configuration cannot express, +because `spark.ui.filters`, the ACLs and the TLS connector are all UI-wide. It bearer-guards (or, if +no `token` is set, leaves open) `/metrics/*` for a Prometheus/vmagent scraper that cannot perform +Kerberos, while optionally delegating every other path to an embedded Hadoop `AuthenticationFilter` +for SPNEGO. Register **only** this filter (it embeds the SPNEGO delegate; do not also list the Hadoop +filter, which would challenge `/metrics/*` too): + +``` +spark.ui.filters=org.apache.spark.filter.MetricsAuthFilter +# bearer token for /metrics/* (omit this line for open metrics): +spark.org.apache.spark.filter.MetricsAuthFilter.param.token= +# optional SPNEGO for the rest of the UI (params carry a 'spnego.' prefix): +spark.org.apache.spark.filter.MetricsAuthFilter.param.spnego.type=kerberos +spark.org.apache.spark.filter.MetricsAuthFilter.param.spnego.kerberos.principal=HTTP/_HOST@REALM +spark.org.apache.spark.filter.MetricsAuthFilter.param.spnego.kerberos.keytab=/etc/security/keytabs/spnego.keytab +``` + +The scraper then presents the token as a `Bearer` credential; on the History Server the registration +is identical (only the TLS namespace differs). + ## Spark History Server ACLs Authentication for the SHS Web UI is enabled the same way as for regular applications, using From c8eb05fea7d27e75cc490d7271c10e1fdf2b8afc Mon Sep 17 00:00:00 2001 From: Petr Fedchenkov Date: Wed, 8 Jul 2026 16:43:23 +0300 Subject: [PATCH 2/2] NGSOK-1852 Add AuthenticationFilter wrapper for config parity with Spark 4 On Spark 3.5 the UI is javax.servlet, so Hadoop's own AuthenticationFilter can protect the whole Web UI directly - no Spark class is strictly required (unlike Spark 4, whose jakarta UI needs the org.apache.spark.filter.AuthenticationFilter shim). This adds a thin org.apache.spark.filter.AuthenticationFilter that forwards init/doFilter/destroy to Hadoop's javax filter (loaded reflectively to compile against the shaded hadoop-client-api; the unshaded hadoop-auth supplies the real filter at runtime). Its only purpose is configuration parity: the same spark.ui.filters value and spark.org.apache.spark.filter.AuthenticationFilter.param.* keys now work on Spark 3.5 and Spark 4 alike, and are shorter than naming Hadoop's class directly. MetricsAuthFilter now delegates to this wrapper so both filters share one class. docs/security.md updated accordingly. --- .../spark/filter/AuthenticationFilter.java | 99 +++++++++++++++++++ .../spark/filter/MetricsAuthFilter.java | 47 ++++----- .../filter/AuthenticationFilterSuite.java | 86 ++++++++++++++++ docs/security.md | 30 +++--- 4 files changed, 218 insertions(+), 44 deletions(-) create mode 100644 core/src/main/java/org/apache/spark/filter/AuthenticationFilter.java create mode 100644 core/src/test/java/org/apache/spark/filter/AuthenticationFilterSuite.java diff --git a/core/src/main/java/org/apache/spark/filter/AuthenticationFilter.java b/core/src/main/java/org/apache/spark/filter/AuthenticationFilter.java new file mode 100644 index 0000000000000..e257bf8246847 --- /dev/null +++ b/core/src/main/java/org/apache/spark/filter/AuthenticationFilter.java @@ -0,0 +1,99 @@ +/* + * 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.spark.filter; + +import java.io.IOException; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; + +/** + * A {@code spark.ui.filters} filter that authenticates the whole Spark Web UI + * with SPNEGO/Kerberos ({@code type=kerberos}) or pseudo ({@code type=simple}), by + * exposing Hadoop's + * {@code org.apache.hadoop.security.authentication.server.AuthenticationFilter} under + * a stable Spark class name. + * + *

Its sole purpose is configuration parity with Spark 4: the same + * {@code spark.ui.filters=org.apache.spark.filter.AuthenticationFilter} value and the + * same {@code spark.org.apache.spark.filter.AuthenticationFilter.param.*} keys work on + * Spark 3.5 and Spark 4 alike (and are shorter than naming Hadoop's class directly). + * + *

On Spark 3.5 the UI is {@code javax.servlet} and Hadoop's AuthenticationFilter is + * a real {@code javax.servlet.Filter}, so this is a thin passthrough that just + * forwards {@code init}/{@code doFilter}/{@code destroy} to it - there is none of the + * jakarta-to-servlet bridging the Spark 4 filter of the same name needs. The delegate + * is created reflectively so this class compiles against the shaded + * {@code hadoop-client-api} on the build classpath (whose AuthenticationFilter + * implements a relocated servlet API); at runtime the unshaded {@code hadoop-auth} + * supplies the concrete filter. Filter init params are forwarded unchanged, so + * Hadoop's filter sees {@code type}, {@code kerberos.principal}, {@code kerberos.keytab} + * and friends. + * + *

Authentication only - pair it with UI TLS ({@code spark.ssl.ui.enabled=true}, or + * {@code spark.ssl.historyServer.*} on the History Server). To apply a different policy + * to the Prometheus {@code /metrics/*} endpoints (for a scraper that cannot do + * Kerberos), use {@link MetricsAuthFilter} instead, which embeds this filter. + */ +public class AuthenticationFilter implements Filter { + + private static final String HADOOP_FILTER = + "org.apache.hadoop.security.authentication.server.AuthenticationFilter"; + + private Filter delegate; + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + delegate = createDelegate(); + delegate.init(filterConfig); // params forwarded unchanged + } + + /** + * Hadoop's javax {@code AuthenticationFilter}, loaded reflectively to avoid a + * compile-time reference to the shaded {@code hadoop-client-api} type (whose + * AuthenticationFilter implements a relocated {@code javax.servlet.Filter} and so + * cannot be cast to the real one). At runtime the unshaded {@code hadoop-auth} + * supplies a real {@code javax.servlet.Filter}. Package-private and overridable so + * tests can inject a stub without Kerberos/Hadoop. + */ + Filter createDelegate() throws ServletException { + try { + return (Filter) Class.forName(HADOOP_FILTER).getDeclaredConstructor().newInstance(); + } catch (ReflectiveOperationException e) { + throw new ServletException( + "Hadoop AuthenticationFilter (" + HADOOP_FILTER + ") is not on the classpath", e); + } + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + delegate.doFilter(request, response, chain); + } + + @Override + public void destroy() { + if (delegate != null) { + delegate.destroy(); + delegate = null; + } + } +} diff --git a/core/src/main/java/org/apache/spark/filter/MetricsAuthFilter.java b/core/src/main/java/org/apache/spark/filter/MetricsAuthFilter.java index 013ff3c3b0176..b9c834341fbef 100644 --- a/core/src/main/java/org/apache/spark/filter/MetricsAuthFilter.java +++ b/core/src/main/java/org/apache/spark/filter/MetricsAuthFilter.java @@ -46,12 +46,11 @@ * "no token" mode) - useful when the scraper cannot present credentials but * the rest of the UI still must be protected. *

  • UI auth via SPNEGO/pseudo - when the {@code spnego.type} param is - * present, every NON-metrics path is delegated to Hadoop's - * {@code org.apache.hadoop.security.authentication.server.AuthenticationFilter} - * ({@code type=kerberos} for SPNEGO, {@code simple} for pseudo) - a real - * {@code javax.servlet.Filter} on this Spark 3.5 (javax) build. The metrics - * path NEVER hits SPNEGO, so vmagent/Prometheus (which cannot do Kerberos) - * can still scrape.
  • + * present, every NON-metrics path is delegated to {@link AuthenticationFilter} + * ({@code type=kerberos} for SPNEGO, {@code simple} for pseudo), the thin + * wrapper around Hadoop's javax AuthenticationFilter. The metrics path NEVER + * hits SPNEGO, so vmagent/Prometheus (which cannot do Kerberos) can still + * scrape. *
  • Fall-through - with neither param set for a given path, the * request passes to Spark's own filters/ACLs unchanged.
  • * @@ -64,10 +63,9 @@ * {@code AuthenticationFilter} as well. * *

    If you do NOT need path-scoping (i.e. you are happy protecting the WHOLE UI, - * metrics included), skip this class and list Hadoop's - * {@code org.apache.hadoop.security.authentication.server.AuthenticationFilter} - * (SPNEGO) in {@code spark.ui.filters} directly - on Spark 3.5 it is a real - * {@code javax.servlet.Filter} and needs no wrapper. + * metrics included), skip this class and list + * {@code org.apache.spark.filter.AuthenticationFilter} (SPNEGO) in + * {@code spark.ui.filters} directly. * *

    Modes (combinations of the two params)

    *
    @@ -88,8 +86,10 @@
      * --conf spark.org.apache.spark.filter.MetricsAuthFilter.param.token=<shared-secret>
      * # optional SPNEGO for the rest of the UI (params carry a 'spnego.' prefix):
      * --conf spark.org.apache.spark.filter.MetricsAuthFilter.param.spnego.type=kerberos
    - * --conf spark.org.apache.spark.filter.MetricsAuthFilter.param.spnego.kerberos.principal=HTTP/_HOST@REALM
    - * --conf spark.org.apache.spark.filter.MetricsAuthFilter.param.spnego.kerberos.keytab=/etc/keytabs/spnego.keytab
    + * --conf spark.org.apache.spark.filter.MetricsAuthFilter.param.spnego.kerberos.\
    + *   principal=HTTP/_HOST@REALM
    + * --conf spark.org.apache.spark.filter.MetricsAuthFilter.param.spnego.kerberos.\
    + *   keytab=/etc/keytabs/spnego.keytab
      * # confidentiality: also enable TLS so the token is not sent in cleartext
      * --conf spark.ssl.ui.enabled=true
      * 
    @@ -140,26 +140,13 @@ public void init(FilterConfig cfg) throws ServletException { } /** - * The filter that non-metrics UI paths are delegated to. On this - * (Spark 3.5.x, javax.servlet) build it is Hadoop's own - * {@code org.apache.hadoop.security.authentication.server.AuthenticationFilter}, - * a real {@code javax.servlet.Filter}. It is loaded reflectively so this class - * compiles against the shaded {@code hadoop-client-api} on the build classpath - * (whose AuthenticationFilter implements a relocated servlet API); at runtime - * the unshaded {@code hadoop-auth} supplies the concrete filter. Spark 4 needs a - * jakarta wrapper ({@code org.apache.spark.filter.AuthenticationFilter}) here - * because its UI is jakarta.servlet; Spark 3.5 does not. - * Package-private and overridable so tests can inject a stub without Kerberos. + * The filter that non-metrics UI paths are delegated to: {@link AuthenticationFilter}, + * the thin wrapper that exposes Hadoop's javax AuthenticationFilter under a stable + * Spark class name (same class used on Spark 4). Package-private and overridable so + * tests can inject a stub without Kerberos. */ Filter createUiDelegate() { - try { - return (Filter) Class.forName( - "org.apache.hadoop.security.authentication.server.AuthenticationFilter") - .getDeclaredConstructor().newInstance(); - } catch (ReflectiveOperationException e) { - throw new IllegalStateException( - "Hadoop AuthenticationFilter is not on the classpath", e); - } + return new AuthenticationFilter(); } @Override diff --git a/core/src/test/java/org/apache/spark/filter/AuthenticationFilterSuite.java b/core/src/test/java/org/apache/spark/filter/AuthenticationFilterSuite.java new file mode 100644 index 0000000000000..b4ce1bf9f923b --- /dev/null +++ b/core/src/test/java/org/apache/spark/filter/AuthenticationFilterSuite.java @@ -0,0 +1,86 @@ +/* + * 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.spark.filter; + +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.io.IOException; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; + +import org.junit.Test; + +/** + * Unit tests for {@link AuthenticationFilter}, the thin wrapper around Hadoop's javax + * AuthenticationFilter. The Hadoop delegate is replaced with a recording stub via the + * package-private {@code createDelegate()} seam, so no Hadoop/Kerberos is needed. + */ +public class AuthenticationFilterSuite { + + @Test + public void forwardsLifecycleAndRequestsToDelegate() throws Exception { + RecordingFilter stub = new RecordingFilter(); + AuthenticationFilter filter = new AuthenticationFilter() { + @Override + Filter createDelegate() { + return stub; + } + }; + + FilterConfig cfg = mock(FilterConfig.class); + filter.init(cfg); + assertSame("init must be forwarded to the Hadoop delegate", cfg, stub.initConfig); + + ServletRequest req = mock(ServletRequest.class); + ServletResponse resp = mock(ServletResponse.class); + FilterChain chain = mock(FilterChain.class); + filter.doFilter(req, resp, chain); + verify(chain).doFilter(req, resp); // the stub continues the chain + + filter.destroy(); + assertTrue("destroy must be forwarded to the delegate", stub.destroyed); + } + + private static final class RecordingFilter implements Filter { + private FilterConfig initConfig; + private boolean destroyed; + + @Override + public void init(FilterConfig filterConfig) { + this.initConfig = filterConfig; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + chain.doFilter(request, response); + } + + @Override + public void destroy() { + this.destroyed = true; + } + } +} diff --git a/docs/security.md b/docs/security.md index e8e6951cab764..85da13eea4773 100644 --- a/docs/security.md +++ b/docs/security.md @@ -408,30 +408,32 @@ control who has the respective privileges via YARN interfaces. ### Built-in authentication filters -Apache Spark ships no Web UI authentication filter, but Hadoop's -`org.apache.hadoop.security.authentication.server.AuthenticationFilter` is a `javax.servlet.Filter` -that plugs directly into `spark.ui.filters` and protects the **whole** Web UI with SPNEGO/Kerberos -(`type=kerberos`) or pseudo (`type=simple`) authentication. Filter parameters are passed as +`org.apache.spark.filter.AuthenticationFilter` protects the **whole** Web UI with SPNEGO/Kerberos +(`type=kerberos`) or pseudo (`type=simple`) authentication. On Spark 3.5 it is a thin wrapper around +Hadoop's `org.apache.hadoop.security.authentication.server.AuthenticationFilter` (a real +`javax.servlet.Filter`), exposed under a stable Spark class name so the same configuration works on +Spark 3.5 and Spark 4. Filter parameters are passed as `spark..param.` (the convention Spark uses for any UI filter): ``` -spark.ui.filters=org.apache.hadoop.security.authentication.server.AuthenticationFilter -spark.org.apache.hadoop.security.authentication.server.AuthenticationFilter.param.type=kerberos -spark.org.apache.hadoop.security.authentication.server.AuthenticationFilter.param.kerberos.principal=HTTP/_HOST@REALM -spark.org.apache.hadoop.security.authentication.server.AuthenticationFilter.param.kerberos.keytab=/etc/security/keytabs/spnego.keytab +spark.ui.filters=org.apache.spark.filter.AuthenticationFilter +spark.org.apache.spark.filter.AuthenticationFilter.param.type=kerberos +spark.org.apache.spark.filter.AuthenticationFilter.param.kerberos.principal=HTTP/_HOST@REALM +spark.org.apache.spark.filter.AuthenticationFilter.param.kerberos.keytab=/etc/security/keytabs/spnego.keytab ``` -This distribution additionally ships `org.apache.spark.filter.MetricsAuthFilter` in `spark-core` -(no extra jar required). Both authenticate only - pair them with UI TLS (`spark.ssl.ui.enabled=true`, -or `spark.ssl.historyServer.*` on the History Server) so credentials are not sent in the clear. +This distribution additionally ships `org.apache.spark.filter.MetricsAuthFilter` (also in +`spark-core`, no extra jar required). Both authenticate only - pair them with UI TLS +(`spark.ssl.ui.enabled=true`, or `spark.ssl.historyServer.*` on the History Server) so credentials +are not sent in the clear. `MetricsAuthFilter` applies **different** policies to the Prometheus metrics endpoints (`/metrics/*`) and to the rest of the UI - something plain configuration cannot express, because `spark.ui.filters`, the ACLs and the TLS connector are all UI-wide. It bearer-guards (or, if no `token` is set, leaves open) `/metrics/*` for a Prometheus/vmagent scraper that cannot perform -Kerberos, while optionally delegating every other path to an embedded Hadoop `AuthenticationFilter` -for SPNEGO. Register **only** this filter (it embeds the SPNEGO delegate; do not also list the Hadoop -filter, which would challenge `/metrics/*` too): +Kerberos, while optionally delegating every other path to an embedded `AuthenticationFilter` for +SPNEGO. Register **only** this filter (it embeds the SPNEGO delegate; do not also list +`AuthenticationFilter`, which would challenge `/metrics/*` too): ``` spark.ui.filters=org.apache.spark.filter.MetricsAuthFilter