You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@atlas.apache.org by ni...@apache.org on 2026/10/18 11:26:04 UTC

(atlas) branch master updated: ATLAS-5430: Add SPIFFE header authentication for service-to-service calls. (#786)

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

nixonrodrigues pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/atlas.git


The following commit(s) were added to refs/heads/master by this push:
     new 238f7e9a8 ATLAS-5430: Add SPIFFE header authentication for service-to-service calls. (#786)
238f7e9a8 is described below

commit 238f7e9a8844a1582819bb2b00087ecbf230528f
Author: Nixon Rodrigues <ni...@apache.org>
AuthorDate: Sun Oct 18 16:56:00 2026 +0530

    ATLAS-5430: Add SPIFFE header authentication for service-to-service calls. (#786)
    
    * ATLAS-5430: Add SPIFFE header authentication for service-to-service calls.
    
    Align Atlas header pre-auth with Ranger by accepting configured SPIFFE ID
    headers (e.g. X-Spiffe-Id) and using the service-account segment
    as the trusted-proxy principal when no username header is present.
    
    Co-authored-by: Cursor <cu...@cursor.com>
    
    * ATLAS-5430: Load SPIFFE header names with getStringArray for list config.
    
    Comma-separated atlas.authentication.method.header.spiffe values were truncated
    when read via getString; use getStringArray and extend tests. Document properties
    and enable header-auth Docker settings for local E2E verification.
    
    Co-authored-by: Cursor <cu...@cursor.com>
    
    * ATLAS-5430 - Address review comments in PR #786
    
    ---------
    
    Co-authored-by: Cursor <cu...@cursor.com>
---
 .../java/org/apache/atlas/utils/SpiffeIdUtil.java  | 145 +++++++++++++++++++++
 .../org/apache/atlas/utils/SpiffeIdUtilTest.java   | 105 +++++++++++++++
 .../atlas/postgres/atlas-application.properties    |   7 +
 distro/src/conf/atlas-application.properties       |  10 +-
 .../web/filters/AtlasHeaderPreAuthFilter.java      |  46 ++++++-
 .../web/filters/AtlasHeaderPreAuthFilterTest.java  | 102 +++++++++++++++
 6 files changed, 410 insertions(+), 5 deletions(-)

diff --git a/common/src/main/java/org/apache/atlas/utils/SpiffeIdUtil.java b/common/src/main/java/org/apache/atlas/utils/SpiffeIdUtil.java
new file mode 100644
index 000000000..9fd52c62c
--- /dev/null
+++ b/common/src/main/java/org/apache/atlas/utils/SpiffeIdUtil.java
@@ -0,0 +1,145 @@
+/**
+ * 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
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * 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.atlas.utils;
+
+import org.apache.commons.lang3.StringUtils;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Helper for validating a SPIFFE ID and extracting the service-account identity from it.
+ *
+ * <p>A SPIFFE ID is expected in the exact form {@code spiffe://<trust-domain>/ns/<namespace>/sa/<service-account>};
+ * for service-to-service authentication the receiving server trusts the workload
+ * identity carried in a header (e.g. {@code X-Spiffe-Id}) and uses
+ * the trailing service-account segment as the authenticated principal.
+ */
+public final class SpiffeIdUtil {
+    private static final String HEADER_NAMES_SEP = ",";
+
+    /**
+     * Matches {@code spiffe://<trust-domain>/ns/<namespace>/sa/<service-account>} using the character
+     * sets from the SPIFFE-ID specification:
+     * <ul>
+     *   <li>trust-domain: lowercase letters, digits, {@code .}, {@code -}, {@code _} (the spec requires
+     *       the trust domain to be lowercase);
+     *   <li>namespace and service-account: ASCII letters (any case), digits, {@code .}, {@code -}, {@code _}
+     *       (SPIFFE path segments are case-sensitive).
+     * </ul>
+     * All segments must be non-empty. This is an allow-list of safe characters, so whitespace, control
+     * characters and {@code /} are excluded, keeping the extracted principal well-formed. Per the spec,
+     * the namespace and service-account path segments must not be the relative modifiers {@code .} or
+     * {@code ..} (enforced by the leading negative look-aheads). The service-account is exposed via the
+     * named group {@code sa}.
+     */
+    private static final Pattern SPIFFE_ID_PATTERN = Pattern.compile(
+            "^spiffe://[a-z0-9._-]+/ns/(?!\\.{1,2}/)[A-Za-z0-9._-]+/sa/(?<sa>(?!\\.{1,2}$)[A-Za-z0-9._-]+)$");
+
+    private SpiffeIdUtil() {
+        // to block instantiation
+    }
+
+    /**
+     * Parses SPIFFE header names from {@link org.apache.commons.configuration2.Configuration#getStringArray(String)}.
+     * Atlas {@link org.apache.atlas.ApplicationProperties} uses a comma list delimiter, so a property such as
+     * {@code x-source, x-upstream} must be read with {@code getStringArray}; {@code getString} returns only the
+     * first element.
+     *
+     * @param configValues values from configuration (may be {@code null} or empty)
+     * @return an ordered, immutable list of trimmed, non-empty header names
+     */
+    public static List<String> parseHeaderNames(String[] configValues) {
+        if (configValues == null || configValues.length == 0) {
+            return Collections.emptyList();
+        }
+
+        List<String> ret = new ArrayList<>();
+
+        for (String configValue : configValues) {
+            ret.addAll(parseHeaderNamesFromSingleConfigValue(configValue));
+        }
+
+        return Collections.unmodifiableList(ret);
+    }
+
+    /**
+     * Parses one configuration entry that may itself contain comma-separated header names (for tests and
+     * programmatic use). Prefer {@link #parseHeaderNames(String[])} with {@code getStringArray} at runtime.
+     */
+    public static List<String> parseHeaderNames(String configValue) {
+        return parseHeaderNamesFromSingleConfigValue(configValue);
+    }
+
+    private static List<String> parseHeaderNamesFromSingleConfigValue(String configValue) {
+        if (StringUtils.isBlank(configValue)) {
+            return Collections.emptyList();
+        }
+
+        List<String> ret = new ArrayList<>();
+
+        for (String name : configValue.split(HEADER_NAMES_SEP)) {
+            String trimmed = StringUtils.trimToNull(name);
+
+            if (trimmed != null) {
+                ret.add(trimmed);
+            }
+        }
+
+        return Collections.unmodifiableList(ret);
+    }
+
+    /**
+     * Validates that the given value is a well-formed SPIFFE ID in the exact form
+     * {@code spiffe://<trust-domain>/ns/<namespace>/sa/<service-account>} with non-empty segments.
+     *
+     * @param spiffeId the raw SPIFFE ID value from the request header
+     * @return {@code true} if the value matches the expected SPIFFE ID format; {@code false} otherwise
+     */
+    public static boolean isValidSpiffeId(String spiffeId) {
+        return matchSpiffeId(spiffeId) != null;
+    }
+
+    /**
+     * Extracts the service-account name from a SPIFFE ID such as
+     * {@code spiffe://my-cluster/ns/service-namespace/sa/service-sa}. The value must match the exact
+     * SPIFFE ID format (see {@link #isValidSpiffeId(String)}); otherwise {@code null} is returned.
+     *
+     * @param spiffeId the raw SPIFFE ID value from the request header
+     * @return the service-account segment (e.g. {@code service-sa}), or
+     *         {@code null} if the input is blank or not a well-formed SPIFFE ID
+     */
+    public static String extractServiceAccount(String spiffeId) {
+        Matcher matcher = matchSpiffeId(spiffeId);
+
+        return matcher != null ? matcher.group("sa") : null;
+    }
+
+    private static Matcher matchSpiffeId(String spiffeId) {
+        if (StringUtils.isBlank(spiffeId)) {
+            return null;
+        }
+
+        Matcher matcher = SPIFFE_ID_PATTERN.matcher(spiffeId.trim());
+
+        return matcher.matches() ? matcher : null;
+    }
+}
diff --git a/common/src/test/java/org/apache/atlas/utils/SpiffeIdUtilTest.java b/common/src/test/java/org/apache/atlas/utils/SpiffeIdUtilTest.java
new file mode 100644
index 000000000..22d479272
--- /dev/null
+++ b/common/src/test/java/org/apache/atlas/utils/SpiffeIdUtilTest.java
@@ -0,0 +1,105 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * 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.atlas.utils;
+
+import org.testng.annotations.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertTrue;
+
+public class SpiffeIdUtilTest {
+    private static final String VALID = "spiffe://my-cluster/ns/service-namespace/sa/service-sa";
+
+    @Test
+    public void isValidSpiffeId_acceptsExactFormat() {
+        assertTrue(SpiffeIdUtil.isValidSpiffeId(VALID));
+        assertTrue(SpiffeIdUtil.isValidSpiffeId("  " + VALID + "  "));
+    }
+
+    @Test
+    public void isValidSpiffeId_rejectsMalformed() {
+        assertFalse(SpiffeIdUtil.isValidSpiffeId(null));
+        assertFalse(SpiffeIdUtil.isValidSpiffeId(""));
+        assertFalse(SpiffeIdUtil.isValidSpiffeId("   "));
+        assertFalse(SpiffeIdUtil.isValidSpiffeId("not-a-spiffe-id"));
+        assertFalse(SpiffeIdUtil.isValidSpiffeId("https://my-cluster/ns/n/sa/s"));
+        assertFalse(SpiffeIdUtil.isValidSpiffeId("spiffe://my-cluster/sa/service-sa"));
+        assertFalse(SpiffeIdUtil.isValidSpiffeId("spiffe://my-cluster/ns/n"));
+        assertFalse(SpiffeIdUtil.isValidSpiffeId("spiffe://my-cluster/ns//sa/s"));
+        assertFalse(SpiffeIdUtil.isValidSpiffeId("spiffe:///ns/n/sa/s"));
+        assertFalse(SpiffeIdUtil.isValidSpiffeId("spiffe://my-cluster/ns/n/sa/"));
+        assertFalse(SpiffeIdUtil.isValidSpiffeId(VALID + "/"));
+        assertFalse(SpiffeIdUtil.isValidSpiffeId(VALID + "/extra"));
+        assertFalse(SpiffeIdUtil.isValidSpiffeId("spiffe://my-cluster/sa/s/ns/n"));
+    }
+
+    @Test
+    public void extractServiceAccount_returnsSaOnlyForValidId() {
+        assertEquals(SpiffeIdUtil.extractServiceAccount(VALID), "service-sa");
+        assertEquals(SpiffeIdUtil.extractServiceAccount("  " + VALID + "  "), "service-sa");
+    }
+
+    @Test
+    public void extractServiceAccount_returnsNullForMalformedId() {
+        assertNull(SpiffeIdUtil.extractServiceAccount(null));
+        assertNull(SpiffeIdUtil.extractServiceAccount(""));
+        assertNull(SpiffeIdUtil.extractServiceAccount("not-a-spiffe-id"));
+        assertNull(SpiffeIdUtil.extractServiceAccount("spiffe://my-cluster/sa/service-sa"));
+        assertNull(SpiffeIdUtil.extractServiceAccount("spiffe://my-cluster/ns/n/sa/"));
+        assertNull(SpiffeIdUtil.extractServiceAccount(VALID + "/extra"));
+    }
+
+    @Test
+    public void rejectsSpecValidButNonConformingSpiffeId() {
+        String workloadPath = "spiffe://example.org/workload/frontend";
+        String nsOnly       = "spiffe://example.org/ns/prod";
+
+        assertFalse(SpiffeIdUtil.isValidSpiffeId(workloadPath));
+        assertFalse(SpiffeIdUtil.isValidSpiffeId(nsOnly));
+        assertNull(SpiffeIdUtil.extractServiceAccount(workloadPath));
+        assertNull(SpiffeIdUtil.extractServiceAccount(nsOnly));
+    }
+
+    @Test
+    public void parseHeaderNames_handlesSingleAndMultiple() {
+        assertTrue(SpiffeIdUtil.parseHeaderNames((String) null).isEmpty());
+        assertTrue(SpiffeIdUtil.parseHeaderNames("   ").isEmpty());
+        assertEquals(SpiffeIdUtil.parseHeaderNames("x-awc-source-workload-id"),
+                Collections.singletonList("x-awc-source-workload-id"));
+
+        List<String> names = SpiffeIdUtil.parseHeaderNames(" x-source , x-upstream ,");
+
+        assertEquals(names, Arrays.asList("x-source", "x-upstream"));
+    }
+
+    @Test
+    public void parseHeaderNames_fromStringArray_matchesGetStringArraySemantics() {
+        assertTrue(SpiffeIdUtil.parseHeaderNames((String[]) null).isEmpty());
+        assertTrue(SpiffeIdUtil.parseHeaderNames(new String[0]).isEmpty());
+
+        List<String> names = SpiffeIdUtil.parseHeaderNames(new String[] {"x-source", "x-upstream"});
+
+        assertEquals(names, Arrays.asList("x-source", "x-upstream"));
+    }
+}
diff --git a/dev-support/atlas-docker/config/atlas/postgres/atlas-application.properties b/dev-support/atlas-docker/config/atlas/postgres/atlas-application.properties
index e15bac245..acc2efa80 100644
--- a/dev-support/atlas-docker/config/atlas/postgres/atlas-application.properties
+++ b/dev-support/atlas-docker/config/atlas/postgres/atlas-application.properties
@@ -79,6 +79,13 @@ atlas.authentication.method.file=true
 atlas.authentication.method.ldap.type=none
 atlas.authentication.method.file.filename=${sys:atlas.home}/conf/users-credentials.properties
 
+######### Header Based Authentication #########
+atlas.authentication.method.header.enabled=false
+atlas.authentication.method.header.username=X-Forwarded-User
+atlas.authentication.method.header.roles=X-Forwarded-Groups
+atlas.authentication.method.header.requestid=X-Request-Id
+atlas.authentication.method.header.spiffe=X-Spiffe-Id
+
 #########  Server Properties  #########
 
 atlas.rest.address=http://localhost:21000
diff --git a/distro/src/conf/atlas-application.properties b/distro/src/conf/atlas-application.properties
index df638a8ea..a77076996 100755
--- a/distro/src/conf/atlas-application.properties
+++ b/distro/src/conf/atlas-application.properties
@@ -175,9 +175,13 @@ atlas.authentication.method.file.filename=${sys:atlas.home}/conf/users-credentia
 
 ######### Header Based Authentication #########
 #atlas.authentication.method.header.enabled=false
-#atlas.authentication.method.header.username=username
-#atlas.authentication.method.header.roles=roles
-#atlas.authentication.method.header.request-id=requestid
+#atlas.authentication.method.header.username=X-Forwarded-User
+#atlas.authentication.method.header.roles=X-Forwarded-Groups
+#atlas.authentication.method.header.requestid=X-Request-Id
+# Comma-separated SPIFFE ID header name(s) for service-to-service auth (e.g. X-Spiffe-Id).
+# Read via getStringArray (Atlas list delimiter is comma); multiple headers are tried in order.
+# Used when the username header is absent; the service-account segment of a well-formed SPIFFE ID becomes the principal.
+#atlas.authentication.method.header.spiffe=X-Spiffe-Id
 
 #########  JAAS Configuration ########
 
diff --git a/webapp/src/main/java/org/apache/atlas/web/filters/AtlasHeaderPreAuthFilter.java b/webapp/src/main/java/org/apache/atlas/web/filters/AtlasHeaderPreAuthFilter.java
index 71ed15cd7..ebb0d7caa 100644
--- a/webapp/src/main/java/org/apache/atlas/web/filters/AtlasHeaderPreAuthFilter.java
+++ b/webapp/src/main/java/org/apache/atlas/web/filters/AtlasHeaderPreAuthFilter.java
@@ -22,6 +22,7 @@ import org.apache.atlas.ApplicationProperties;
 import org.apache.atlas.server.common.filters.AtlasResponseRequestWrapper;
 import org.apache.atlas.server.common.filters.HeadersUtil;
 import org.apache.atlas.server.common.model.User;
+import org.apache.atlas.utils.SpiffeIdUtil;
 import org.apache.commons.configuration2.Configuration;
 import org.apache.commons.lang3.StringUtils;
 import org.slf4j.Logger;
@@ -45,6 +46,7 @@ import javax.servlet.http.HttpServletResponse;
 
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 import java.util.UUID;
 
@@ -55,13 +57,15 @@ public class AtlasHeaderPreAuthFilter implements Filter {
     public static final String PROP_HEADER_AUTH_ENABLED = "atlas.authentication.method.header.enabled";
     public static final String PROP_USERNAME_HEADER     = "atlas.authentication.method.header.username";
     public static final String PROP_ROLES_HEADER        = "atlas.authentication.method.header.roles";
-    public static final String PROP_REQUEST_ID_HEADER   = "atlas.authentication.method.header.request-id";
+    public static final String PROP_REQUEST_ID_HEADER   = "atlas.authentication.method.header.requestid";
+    public static final String PROP_SPIFFE_HEADER       = "atlas.authentication.method.header.spiffe";
     public static final String REQUEST_ID_ATTRIBUTE     = "atlas.request.id";
 
     private Configuration configuration;
     private boolean       headerAuthEnabled;
     private String        userNameHeaderName;
     private String        rolesHeaderName;
+    private List<String>  spiffeHeaderNames;
 
     @Inject
     public AtlasHeaderPreAuthFilter() {
@@ -79,11 +83,49 @@ public class AtlasHeaderPreAuthFilter implements Filter {
             headerAuthEnabled  = false;
             userNameHeaderName = null;
             rolesHeaderName    = null;
+            spiffeHeaderNames  = Collections.emptyList();
             return;
         }
         headerAuthEnabled  = configuration.getBoolean(PROP_HEADER_AUTH_ENABLED, false);
         userNameHeaderName = StringUtils.trimToNull(configuration.getString(PROP_USERNAME_HEADER, ""));
         rolesHeaderName    = StringUtils.trimToNull(configuration.getString(PROP_ROLES_HEADER, ""));
+        spiffeHeaderNames  = SpiffeIdUtil.parseHeaderNames(configuration.getStringArray(PROP_SPIFFE_HEADER));
+
+        if (headerAuthEnabled && userNameHeaderName == null && spiffeHeaderNames.isEmpty()) {
+            LOG.warn("Disabling header-based authentication, as neither {} nor {} is set",
+                    PROP_USERNAME_HEADER, PROP_SPIFFE_HEADER);
+            headerAuthEnabled = false;
+        }
+    }
+
+    /**
+     * Resolves the principal from trusted headers. The username header (user identity) takes
+     * precedence; when it is absent, configured SPIFFE headers (service identity) are consulted and
+     * the trailing service-account segment of the SPIFFE ID becomes the principal.
+     */
+    private String resolvePrincipal(HttpServletRequest httpRequest) {
+        String username = userNameHeaderName != null
+                ? StringUtils.trimToNull(httpRequest.getHeader(userNameHeaderName))
+                : null;
+
+        if (username != null) {
+            return username;
+        }
+
+        for (String spiffeHeaderName : spiffeHeaderNames) {
+            String spiffeId       = StringUtils.trimToNull(httpRequest.getHeader(spiffeHeaderName));
+            String serviceAccount = SpiffeIdUtil.extractServiceAccount(spiffeId);
+
+            if (serviceAccount != null) {
+                LOG.debug("Resolved service-account '{}' from SPIFFE header '{}'", serviceAccount, spiffeHeaderName);
+
+                return serviceAccount;
+            } else if (spiffeId != null) {
+                LOG.warn("SPIFFE header '{}' value is not a well-formed SPIFFE ID", spiffeHeaderName);
+            }
+        }
+
+        return null;
     }
 
     private List<GrantedAuthority> getAuthoritiesFromRolesHeader(String rolesHeader) {
@@ -121,7 +163,7 @@ public class AtlasHeaderPreAuthFilter implements Filter {
                 Authentication pre = SecurityContextHolder.getContext().getAuthentication();
 
                 if (pre == null || !pre.isAuthenticated()) {
-                    String username = StringUtils.trimToNull(httpRequest.getHeader(userNameHeaderName));
+                    String username = resolvePrincipal(httpRequest);
 
                     if (username != null) {
                         List<GrantedAuthority> grantedAuths =
diff --git a/webapp/src/test/java/org/apache/atlas/web/filters/AtlasHeaderPreAuthFilterTest.java b/webapp/src/test/java/org/apache/atlas/web/filters/AtlasHeaderPreAuthFilterTest.java
index b74452e3a..bf58e0e97 100644
--- a/webapp/src/test/java/org/apache/atlas/web/filters/AtlasHeaderPreAuthFilterTest.java
+++ b/webapp/src/test/java/org/apache/atlas/web/filters/AtlasHeaderPreAuthFilterTest.java
@@ -77,6 +77,8 @@ public class AtlasHeaderPreAuthFilterTest {
                 .thenReturn("X-Forwarded-User");
         when(configuration.getString(AtlasHeaderPreAuthFilter.PROP_ROLES_HEADER, ""))
                 .thenReturn("X-Forwarded-Groups");
+        when(configuration.getStringArray(AtlasHeaderPreAuthFilter.PROP_SPIFFE_HEADER))
+                .thenReturn(new String[0]);
         when(request.getHeader("X-Forwarded-User")).thenReturn("alice");
         when(request.getHeader("X-Forwarded-Groups")).thenReturn("ROLE_ADMIN, ROLE_USER");
 
@@ -107,6 +109,8 @@ public class AtlasHeaderPreAuthFilterTest {
                 .thenReturn("X-Forwarded-User");
         when(configuration.getString(AtlasHeaderPreAuthFilter.PROP_ROLES_HEADER, ""))
                 .thenReturn("X-Forwarded-Groups");
+        when(configuration.getStringArray(AtlasHeaderPreAuthFilter.PROP_SPIFFE_HEADER))
+                .thenReturn(new String[0]);
         when(request.getHeader("X-Forwarded-User")).thenReturn("   ");
 
         try (MockedStatic<ApplicationProperties> appProps = org.mockito.Mockito.mockStatic(ApplicationProperties.class)) {
@@ -141,4 +145,102 @@ public class AtlasHeaderPreAuthFilterTest {
         assertSame(SecurityContextHolder.getContext().getAuthentication(), existing);
         verify(filterChain).doFilter(eq(request), any(HttpServletResponse.class));
     }
+
+    @Test
+    public void testDoFilterEnabledWithSpiffeHeaderSetsServiceAccountAuthentication() throws Exception {
+        when(configuration.getBoolean(AtlasHeaderPreAuthFilter.PROP_HEADER_AUTH_ENABLED, false)).thenReturn(true);
+        when(configuration.getString(AtlasHeaderPreAuthFilter.PROP_USERNAME_HEADER, ""))
+                .thenReturn("");
+        when(configuration.getString(AtlasHeaderPreAuthFilter.PROP_ROLES_HEADER, ""))
+                .thenReturn("");
+        when(configuration.getStringArray(AtlasHeaderPreAuthFilter.PROP_SPIFFE_HEADER))
+                .thenReturn(new String[] {"X-Spiffe-Id"});
+        when(request.getHeader("X-Spiffe-Id"))
+                .thenReturn("spiffe://prod-cluster.k8s.example.com/ns/ingress-nginx/sa/nginx-ingress");
+
+        try (MockedStatic<ApplicationProperties> appProps = org.mockito.Mockito.mockStatic(ApplicationProperties.class)) {
+            appProps.when(ApplicationProperties::get).thenReturn(configuration);
+
+            AtlasHeaderPreAuthFilter filter = new AtlasHeaderPreAuthFilter();
+            filter.doFilter(request, response, filterChain);
+        }
+
+        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+        assertNotNull(auth);
+        assertTrue(auth instanceof AtlasAuthenticationToken);
+
+        UserDetails principal = (UserDetails) ((AtlasAuthenticationToken) auth).getPrincipal();
+        assertEquals(principal.getUsername(), "nginx-ingress");
+        assertEquals(((AtlasAuthenticationToken) auth).getAuthType(), AtlasAuthenticationToken.AUTH_TYPE_TRUSTED_PROXY);
+        verify(filterChain).doFilter(eq(request), any(HttpServletResponse.class));
+    }
+
+    @Test
+    public void testDoFilterEnabledUsernameHeaderTakesPrecedenceOverSpiffe() throws Exception {
+        when(configuration.getBoolean(AtlasHeaderPreAuthFilter.PROP_HEADER_AUTH_ENABLED, false)).thenReturn(true);
+        when(configuration.getString(AtlasHeaderPreAuthFilter.PROP_USERNAME_HEADER, ""))
+                .thenReturn("X-Forwarded-User");
+        when(configuration.getString(AtlasHeaderPreAuthFilter.PROP_ROLES_HEADER, ""))
+                .thenReturn("");
+        when(configuration.getStringArray(AtlasHeaderPreAuthFilter.PROP_SPIFFE_HEADER))
+                .thenReturn(new String[] {"X-Spiffe-Id"});
+        when(request.getHeader("X-Forwarded-User")).thenReturn("alice");
+        when(request.getHeader("X-Spiffe-Id"))
+                .thenReturn("spiffe://my-cluster/ns/service-namespace/sa/service-sa");
+
+        try (MockedStatic<ApplicationProperties> appProps = org.mockito.Mockito.mockStatic(ApplicationProperties.class)) {
+            appProps.when(ApplicationProperties::get).thenReturn(configuration);
+
+            AtlasHeaderPreAuthFilter filter = new AtlasHeaderPreAuthFilter();
+            filter.doFilter(request, response, filterChain);
+        }
+
+        UserDetails principal = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
+        assertEquals(principal.getUsername(), "alice");
+    }
+
+    @Test
+    public void testDoFilterEnabledWithMultipleSpiffeHeadersUsesFirstValid() throws Exception {
+        when(configuration.getBoolean(AtlasHeaderPreAuthFilter.PROP_HEADER_AUTH_ENABLED, false)).thenReturn(true);
+        when(configuration.getString(AtlasHeaderPreAuthFilter.PROP_USERNAME_HEADER, ""))
+                .thenReturn("");
+        when(configuration.getString(AtlasHeaderPreAuthFilter.PROP_ROLES_HEADER, ""))
+                .thenReturn("");
+        when(configuration.getStringArray(AtlasHeaderPreAuthFilter.PROP_SPIFFE_HEADER))
+                .thenReturn(new String[] {"X-Spiffe-Id-Unused", "X-Spiffe-Id"});
+        when(request.getHeader("X-Spiffe-Id-Unused")).thenReturn(null);
+        when(request.getHeader("X-Spiffe-Id"))
+                .thenReturn("spiffe://my-cluster/ns/service-namespace/sa/service-sa");
+
+        try (MockedStatic<ApplicationProperties> appProps = org.mockito.Mockito.mockStatic(ApplicationProperties.class)) {
+            appProps.when(ApplicationProperties::get).thenReturn(configuration);
+
+            AtlasHeaderPreAuthFilter filter = new AtlasHeaderPreAuthFilter();
+            filter.doFilter(request, response, filterChain);
+        }
+
+        UserDetails principal = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
+        assertEquals(principal.getUsername(), "service-sa");
+    }
+
+    @Test
+    public void testDoFilterEnabledMalformedSpiffeHeaderDoesNotAuthenticate() throws Exception {
+        when(configuration.getBoolean(AtlasHeaderPreAuthFilter.PROP_HEADER_AUTH_ENABLED, false)).thenReturn(true);
+        when(configuration.getString(AtlasHeaderPreAuthFilter.PROP_USERNAME_HEADER, ""))
+                .thenReturn("");
+        when(configuration.getString(AtlasHeaderPreAuthFilter.PROP_ROLES_HEADER, ""))
+                .thenReturn("");
+        when(configuration.getStringArray(AtlasHeaderPreAuthFilter.PROP_SPIFFE_HEADER))
+                .thenReturn(new String[] {"X-Spiffe-Id"});
+        when(request.getHeader("X-Spiffe-Id")).thenReturn("not-a-spiffe-id");
+
+        try (MockedStatic<ApplicationProperties> appProps = org.mockito.Mockito.mockStatic(ApplicationProperties.class)) {
+            appProps.when(ApplicationProperties::get).thenReturn(configuration);
+
+            AtlasHeaderPreAuthFilter filter = new AtlasHeaderPreAuthFilter();
+            filter.doFilter(request, response, filterChain);
+        }
+
+        assertEquals(SecurityContextHolder.getContext().getAuthentication(), null);
+    }
 }