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

smolnar82 pushed a commit to branch knox_idf
in repository https://gitbox.apache.org/repos/asf/knox.git

commit d0a12a69fff7b16f2b5a0762da054bf255502e20
Author: hanicz <[email protected]>
AuthorDate: Thu Jun 18 13:47:58 2026 +0200

    KNOX-3352: k8s pre-auth validator for service account annotation (#1268)
    
    * KNOX-3353: New K8s pre-auth provider
    
    * KNOX-3353: Fix k8s deps
    
    (cherry picked from commit 755c4bc1f2c97a16d2f8d79f16a4db089f1ca075)
---
 gateway-provider-security-k8s/pom.xml              |  84 +++++++
 .../gateway/preauth/k8s/K8sLookupException.java    |  25 ++
 .../preauth/k8s/K8sPreAuthFederationFilter.java    | 119 ++++++++++
 .../gateway/preauth/k8s/K8sPreAuthMessages.java    |  59 +++++
 .../preauth/k8s/K8sServiceAccountResolver.java     | 122 ++++++++++
 .../preauth/k8s/ServiceAccountValidator.java       | 100 ++++++++
 .../apache/knox/gateway/preauth/k8s/SpiffeId.java  |  55 +++++
 .../preauth/k8s/deploy/K8sPreAuthContributor.java  |  66 ++++++
 ...ox.gateway.deploy.ProviderDeploymentContributor |  19 ++
 ...he.knox.gateway.preauth.filter.PreAuthValidator |  19 ++
 .../k8s/K8sPreAuthFederationFilterTest.java        | 252 +++++++++++++++++++++
 .../preauth/k8s/K8sServiceAccountResolverTest.java | 214 +++++++++++++++++
 .../preauth/k8s/ServiceAccountValidatorTest.java   | 151 ++++++++++++
 .../knox/gateway/preauth/k8s/SpiffeIdTest.java     |  73 ++++++
 .../filter/AbstractPreAuthFederationFilter.java    |  12 +-
 gateway-release/pom.xml                            |   4 +
 pom.xml                                            |  23 +-
 17 files changed, 1394 insertions(+), 3 deletions(-)

diff --git a/gateway-provider-security-k8s/pom.xml 
b/gateway-provider-security-k8s/pom.xml
new file mode 100644
index 000000000..1f50c5f64
--- /dev/null
+++ b/gateway-provider-security-k8s/pom.xml
@@ -0,0 +1,84 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
http://maven.apache.org/xsd/maven-4.0.0.xsd";>
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>org.apache.knox</groupId>
+        <artifactId>gateway</artifactId>
+        <version>3.0.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>gateway-provider-security-k8s</artifactId>
+    <name>gateway-provider-security-k8s</name>
+    <description>Kubernetes-aware PreAuth trust validators (e.g., SPIFFE ID to 
ServiceAccount annotation matching).</description>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.apache.knox</groupId>
+            <artifactId>gateway-provider-security-preauth</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.knox</groupId>
+            <artifactId>gateway-i18n</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.knox</groupId>
+            <artifactId>gateway-spi</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>javax.servlet</groupId>
+            <artifactId>javax.servlet-api</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>io.fabric8</groupId>
+            <artifactId>kubernetes-client</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.fabric8</groupId>
+            <artifactId>kubernetes-client-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.fabric8</groupId>
+            <artifactId>kubernetes-model-core</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>com.github.ben-manes.caffeine</groupId>
+            <artifactId>caffeine</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.knox</groupId>
+            <artifactId>gateway-test-utils</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.easymock</groupId>
+            <artifactId>easymock</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+</project>
diff --git 
a/gateway-provider-security-k8s/src/main/java/org/apache/knox/gateway/preauth/k8s/K8sLookupException.java
 
b/gateway-provider-security-k8s/src/main/java/org/apache/knox/gateway/preauth/k8s/K8sLookupException.java
new file mode 100644
index 000000000..30d8506a5
--- /dev/null
+++ 
b/gateway-provider-security-k8s/src/main/java/org/apache/knox/gateway/preauth/k8s/K8sLookupException.java
@@ -0,0 +1,25 @@
+/*
+ * 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.knox.gateway.preauth.k8s;
+
+public class K8sLookupException extends RuntimeException {
+
+    public K8sLookupException(Throwable cause) {
+        super(cause);
+    }
+}
diff --git 
a/gateway-provider-security-k8s/src/main/java/org/apache/knox/gateway/preauth/k8s/K8sPreAuthFederationFilter.java
 
b/gateway-provider-security-k8s/src/main/java/org/apache/knox/gateway/preauth/k8s/K8sPreAuthFederationFilter.java
new file mode 100644
index 000000000..975536d65
--- /dev/null
+++ 
b/gateway-provider-security-k8s/src/main/java/org/apache/knox/gateway/preauth/k8s/K8sPreAuthFederationFilter.java
@@ -0,0 +1,119 @@
+/*
+ * 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.knox.gateway.preauth.k8s;
+
+import org.apache.knox.gateway.preauth.filter.AbstractPreAuthFederationFilter;
+
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import java.io.IOException;
+import java.security.Principal;
+import java.time.Duration;
+import java.util.Set;
+
+public class K8sPreAuthFederationFilter extends 
AbstractPreAuthFederationFilter {
+    private String userHeader = ServiceAccountValidator.USER_HEADER_DEFAULT;
+    private K8sServiceAccountResolver resolver;
+
+    @Override
+    public void init(FilterConfig filterConfig) throws ServletException {
+        super.init(filterConfig);
+        String configured = 
filterConfig.getInitParameter(ServiceAccountValidator.USER_HEADER_PARAM);
+        if (configured != null && !configured.isEmpty()) {
+            userHeader = configured;
+        }
+
+        long ttlSeconds = longParam(filterConfig,
+                ServiceAccountValidator.CACHE_TTL_SECONDS_PARAM,
+                ServiceAccountValidator.CACHE_TTL_SECONDS_DEFAULT);
+        long maxSize = longParam(filterConfig,
+                ServiceAccountValidator.CACHE_MAX_SIZE_PARAM,
+                ServiceAccountValidator.CACHE_MAX_SIZE_DEFAULT);
+        if (ttlSeconds <= 0) {
+            throw new 
ServletException(ServiceAccountValidator.CACHE_TTL_SECONDS_PARAM
+                    + " must be > 0 (got " + ttlSeconds + ")");
+        }
+        if (maxSize <= 0) {
+            throw new 
ServletException(ServiceAccountValidator.CACHE_MAX_SIZE_PARAM
+                    + " must be > 0 (got " + maxSize + ")");
+        }
+
+        if (resolver == null) {
+            resolver = createResolver(Duration.ofSeconds(ttlSeconds), maxSize);
+        }
+    }
+
+    @Override
+    public void doFilter(ServletRequest request, ServletResponse response, 
FilterChain chain)
+            throws IOException, ServletException {
+        request.setAttribute(ServiceAccountValidator.RESOLVER_REQUEST_ATTR, 
resolver);
+        try {
+            super.doFilter(request, response, chain);
+        } finally {
+            
request.removeAttribute(ServiceAccountValidator.RESOLVER_REQUEST_ATTR);
+        }
+    }
+
+    @Override
+    public void destroy() {
+        if (resolver != null) {
+            resolver.close();
+            resolver = null;
+        }
+        super.destroy();
+    }
+
+    @Override
+    protected String getPrimaryPrincipal(HttpServletRequest httpRequest) {
+        return httpRequest.getHeader(userHeader);
+    }
+
+    @Override
+    protected void addGroupPrincipals(HttpServletRequest request, 
Set<Principal> principals) {
+    }
+
+    @Override
+    protected String getValidationFailureMessage() {
+        return "Kubernetes pre-authentication failed: SPIFFE/ServiceAccount 
validation rejected the request.";
+    }
+
+    @Override
+    protected String getMissingPrincipalMessage() {
+        return "Missing required user header for Kubernetes 
pre-authentication.";
+    }
+
+    protected K8sServiceAccountResolver createResolver(Duration ttl, long 
maxSize) {
+        return new K8sServiceAccountResolver(ttl, maxSize);
+    }
+
+    private static long longParam(FilterConfig cfg, String name, long 
defaultValue) {
+        final String v = cfg.getInitParameter(name);
+        if (v == null || v.isEmpty()) {
+            return defaultValue;
+        }
+        try {
+            return Long.parseLong(v.trim());
+        } catch (NumberFormatException e) {
+            return defaultValue;
+        }
+    }
+}
diff --git 
a/gateway-provider-security-k8s/src/main/java/org/apache/knox/gateway/preauth/k8s/K8sPreAuthMessages.java
 
b/gateway-provider-security-k8s/src/main/java/org/apache/knox/gateway/preauth/k8s/K8sPreAuthMessages.java
new file mode 100644
index 000000000..4ef137822
--- /dev/null
+++ 
b/gateway-provider-security-k8s/src/main/java/org/apache/knox/gateway/preauth/k8s/K8sPreAuthMessages.java
@@ -0,0 +1,59 @@
+/*
+ * 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.knox.gateway.preauth.k8s;
+
+import org.apache.knox.gateway.i18n.messages.Message;
+import org.apache.knox.gateway.i18n.messages.MessageLevel;
+import org.apache.knox.gateway.i18n.messages.Messages;
+import org.apache.knox.gateway.i18n.messages.StackTrace;
+
+@Messages(logger = "org.apache.knox.gateway.preauth.k8s")
+public interface K8sPreAuthMessages {
+
+    @Message(level = MessageLevel.WARN, text = "Rejecting request: SPIFFE 
header ''{0}'' is missing")
+    void missingSpiffeHeader(String headerName);
+
+    @Message(level = MessageLevel.WARN, text = "Rejecting request: user header 
''{0}'' is missing")
+    void missingUserHeader(String headerName);
+
+    @Message(level = MessageLevel.WARN,
+            text = "Rejecting request: SPIFFE header value ''{0}'' is not a 
parseable k8s SPIFFE ID (asserted user ''{1}'')")
+    void unparseableSpiffeId(String spiffeRaw, String assertedUser);
+
+    @Message(level = MessageLevel.WARN,
+            text = "Rejecting request: ServiceAccount {0}/{1} has no ''{2}'' 
annotation (asserted user ''{3}'', SPIFFE ID ''{4}'')")
+    void missingServiceAccountAnnotation(String namespace,
+                                         String serviceAccount,
+                                         String annotationKey,
+                                         String assertedUser,
+                                         String spiffeId);
+
+    @Message(level = MessageLevel.WARN,
+            text = "Rejecting request: asserted user ''{0}'' does not match 
ServiceAccount {1}/{2} ''{3}'' (SPIFFE ID ''{4}'')")
+    void assertedUserDoesNotMatchAnnotation(String assertedUser,
+                                            String namespace,
+                                            String serviceAccount,
+                                            String annotationKey,
+                                            String spiffeId);
+
+    @Message(level = MessageLevel.ERROR,
+            text = "Failed to load ServiceAccount {0}/{1} from Kubernetes API: 
{2}")
+    void failedToLoadServiceAccount(String namespace,
+                                    String serviceAccount,
+                                    @StackTrace(level = MessageLevel.ERROR) 
Throwable e);
+}
diff --git 
a/gateway-provider-security-k8s/src/main/java/org/apache/knox/gateway/preauth/k8s/K8sServiceAccountResolver.java
 
b/gateway-provider-security-k8s/src/main/java/org/apache/knox/gateway/preauth/k8s/K8sServiceAccountResolver.java
new file mode 100644
index 000000000..6218f1474
--- /dev/null
+++ 
b/gateway-provider-security-k8s/src/main/java/org/apache/knox/gateway/preauth/k8s/K8sServiceAccountResolver.java
@@ -0,0 +1,122 @@
+/*
+ * 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.knox.gateway.preauth.k8s;
+
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.Ticker;
+import io.fabric8.kubernetes.api.model.ServiceAccount;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.KubernetesClientBuilder;
+import org.apache.knox.gateway.i18n.messages.MessagesFactory;
+
+import java.io.Closeable;
+import java.time.Duration;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+public class K8sServiceAccountResolver implements Closeable {
+  private static final K8sPreAuthMessages LOG = 
MessagesFactory.get(K8sPreAuthMessages.class);
+
+  private final KubernetesClient client;
+  private final Cache<Key, Optional<Map<String, String>>> annotationsCache;
+
+  public K8sServiceAccountResolver(Duration ttl, long maxSize) {
+    this(new KubernetesClientBuilder().build(), ttl, maxSize, 
Ticker.systemTicker());
+  }
+
+  K8sServiceAccountResolver(KubernetesClient client, Duration ttl, long 
maxSize) {
+    this(client, ttl, maxSize, Ticker.systemTicker());
+  }
+
+  K8sServiceAccountResolver(KubernetesClient client, Duration ttl, long 
maxSize, Ticker ticker) {
+    this.client = Objects.requireNonNull(client);
+    this.annotationsCache = Caffeine.newBuilder()
+        .expireAfterWrite(ttl)
+        .maximumSize(maxSize)
+        .ticker(ticker)
+        .build();
+  }
+
+  public Optional<String> getAnnotation(String namespace, String 
serviceAccount, String annotationKey) {
+    final Key key = new Key(namespace, serviceAccount);
+    final Optional<Map<String, String>> annotations;
+    try {
+      annotations = annotationsCache.get(key, this::fetchAnnotations);
+    } catch (K8sLookupException e) {
+      LOG.failedToLoadServiceAccount(namespace, serviceAccount, e.getCause());
+      return Optional.empty();
+    }
+    if (annotations.isEmpty()) {
+      return Optional.empty();
+    }
+    return Optional.ofNullable(annotations.get().get(annotationKey));
+  }
+
+  private Optional<Map<String, String>> fetchAnnotations(Key key) {
+    final ServiceAccount sa;
+    try {
+      sa = client.serviceAccounts()
+          .inNamespace(key.namespace)
+          .withName(key.serviceAccount)
+          .get();
+    } catch (Exception e) {
+      // Propagate so Caffeine does NOT cache the failure: a transient API 
error
+      // must not poison the cache for the success TTL.
+      throw new K8sLookupException(e);
+    }
+    if (sa == null || sa.getMetadata() == null) {
+      return Optional.empty();
+    }
+    final Map<String, String> annotations = sa.getMetadata().getAnnotations();
+    return annotations == null ? Optional.empty() : Optional.of(annotations);
+  }
+
+  @Override
+  public void close() {
+      client.close();
+  }
+
+  private static final class Key {
+    final String namespace;
+    final String serviceAccount;
+
+    Key(String namespace, String serviceAccount) {
+      this.namespace = namespace;
+      this.serviceAccount = serviceAccount;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (!(o instanceof Key)) {
+        return false;
+      }
+      Key k = (Key) o;
+      return namespace.equals(k.namespace) && 
serviceAccount.equals(k.serviceAccount);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(namespace, serviceAccount);
+    }
+  }
+}
diff --git 
a/gateway-provider-security-k8s/src/main/java/org/apache/knox/gateway/preauth/k8s/ServiceAccountValidator.java
 
b/gateway-provider-security-k8s/src/main/java/org/apache/knox/gateway/preauth/k8s/ServiceAccountValidator.java
new file mode 100644
index 000000000..db9c8a6e6
--- /dev/null
+++ 
b/gateway-provider-security-k8s/src/main/java/org/apache/knox/gateway/preauth/k8s/ServiceAccountValidator.java
@@ -0,0 +1,100 @@
+/*
+ * 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.knox.gateway.preauth.k8s;
+
+import org.apache.knox.gateway.i18n.messages.MessagesFactory;
+import org.apache.knox.gateway.preauth.filter.PreAuthValidationException;
+import org.apache.knox.gateway.preauth.filter.PreAuthValidator;
+
+import javax.servlet.FilterConfig;
+import javax.servlet.http.HttpServletRequest;
+import java.util.Optional;
+
+public class ServiceAccountValidator implements PreAuthValidator {
+    private static final K8sPreAuthMessages LOG = 
MessagesFactory.get(K8sPreAuthMessages.class);
+
+    public static final String VALIDATION_METHOD_VALUE = 
"preauth.spiffe.k8s.validation";
+    public static final String SPIFFE_HEADER_PARAM = "preauth.spiffe.header";
+    public static final String SPIFFE_HEADER_DEFAULT = "x-spiffe-id";
+    public static final String USER_HEADER_PARAM = "preauth.custom.header";
+    public static final String USER_HEADER_DEFAULT = "x-knoxidf-obo.username";
+    public static final String USER_ANNOTATION_PARAM = 
"preauth.k8s.user.annotation";
+    public static final String USER_ANNOTATION_DEFAULT = 
"knox.apache.org/owner-username";
+    public static final String CACHE_TTL_SECONDS_PARAM = 
"preauth.k8s.cache.ttl.seconds";
+    public static final long CACHE_TTL_SECONDS_DEFAULT = 60L;
+    public static final String CACHE_MAX_SIZE_PARAM = 
"preauth.k8s.cache.max.size";
+    public static final long CACHE_MAX_SIZE_DEFAULT = 1000L;
+    static final String RESOLVER_REQUEST_ATTR = 
"org.apache.knox.gateway.preauth.k8s.resolver";
+
+    public ServiceAccountValidator() {
+    }
+
+    @Override
+    public boolean validate(HttpServletRequest httpRequest, FilterConfig 
filterConfig)
+            throws PreAuthValidationException {
+        final K8sServiceAccountResolver resolver =
+                (K8sServiceAccountResolver) 
httpRequest.getAttribute(RESOLVER_REQUEST_ATTR);
+        final String spiffeHeader = paramOrDefault(filterConfig, 
SPIFFE_HEADER_PARAM, SPIFFE_HEADER_DEFAULT);
+        final String userHeader = paramOrDefault(filterConfig, 
USER_HEADER_PARAM, USER_HEADER_DEFAULT);
+        final String annotationKey = paramOrDefault(filterConfig, 
USER_ANNOTATION_PARAM, USER_ANNOTATION_DEFAULT);
+
+        final String spiffeRaw = httpRequest.getHeader(spiffeHeader);
+        final String assertedUser = httpRequest.getHeader(userHeader);
+
+        if (spiffeRaw == null || spiffeRaw.isEmpty()) {
+            LOG.missingSpiffeHeader(spiffeHeader);
+            return false;
+        }
+        if (assertedUser == null || assertedUser.isEmpty()) {
+            LOG.missingUserHeader(userHeader);
+            return false;
+        }
+
+        final Optional<SpiffeId> parsed = SpiffeId.parse(spiffeRaw);
+        if (parsed.isEmpty()) {
+            LOG.unparseableSpiffeId(spiffeRaw, assertedUser);
+            return false;
+        }
+        final SpiffeId spiffe = parsed.get();
+
+        final Optional<String> ownerFromSa = resolver
+                .getAnnotation(spiffe.namespace(), spiffe.serviceAccount(), 
annotationKey);
+        if (ownerFromSa.isEmpty()) {
+            LOG.missingServiceAccountAnnotation(spiffe.namespace(), 
spiffe.serviceAccount(),
+                    annotationKey, assertedUser, spiffeRaw);
+            return false;
+        }
+
+        final boolean match = ownerFromSa.get().equals(assertedUser);
+        if (!match) {
+            LOG.assertedUserDoesNotMatchAnnotation(assertedUser, 
spiffe.namespace(),
+                    spiffe.serviceAccount(), annotationKey, spiffeRaw);
+        }
+        return match;
+    }
+
+    @Override
+    public String getName() {
+        return VALIDATION_METHOD_VALUE;
+    }
+
+    private static String paramOrDefault(FilterConfig cfg, String name, String 
defaultValue) {
+        final String v = cfg.getInitParameter(name);
+        return (v == null || v.isEmpty()) ? defaultValue : v;
+    }
+}
diff --git 
a/gateway-provider-security-k8s/src/main/java/org/apache/knox/gateway/preauth/k8s/SpiffeId.java
 
b/gateway-provider-security-k8s/src/main/java/org/apache/knox/gateway/preauth/k8s/SpiffeId.java
new file mode 100644
index 000000000..9a5c934cb
--- /dev/null
+++ 
b/gateway-provider-security-k8s/src/main/java/org/apache/knox/gateway/preauth/k8s/SpiffeId.java
@@ -0,0 +1,55 @@
+/*
+ * 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.knox.gateway.preauth.k8s;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Optional;
+
+public record SpiffeId(String trustDomain, String namespace, String 
serviceAccount) {
+    private static final String SCHEME = "spiffe";
+
+    public static Optional<SpiffeId> parse(String value) {
+        if (value == null || value.isEmpty()) {
+            return Optional.empty();
+        }
+        final URI uri;
+        try {
+            uri = new URI(value.trim());
+        } catch (URISyntaxException e) {
+            return Optional.empty();
+        }
+        if (!SCHEME.equalsIgnoreCase(uri.getScheme()) || uri.getHost() == 
null) {
+            return Optional.empty();
+        }
+        final String path = uri.getPath();
+        if (path == null) {
+            return Optional.empty();
+        }
+        final String[] parts = path.split("/");
+        if (parts.length != 5 || !"ns".equals(parts[1]) || 
!"sa".equals(parts[3])) {
+            return Optional.empty();
+        }
+        final String namespace = parts[2];
+        final String serviceAccount = parts[4];
+        if (namespace.isEmpty() || serviceAccount.isEmpty()) {
+            return Optional.empty();
+        }
+        return Optional.of(new SpiffeId(uri.getHost(), namespace, 
serviceAccount));
+    }
+}
diff --git 
a/gateway-provider-security-k8s/src/main/java/org/apache/knox/gateway/preauth/k8s/deploy/K8sPreAuthContributor.java
 
b/gateway-provider-security-k8s/src/main/java/org/apache/knox/gateway/preauth/k8s/deploy/K8sPreAuthContributor.java
new file mode 100644
index 000000000..c88ec7b35
--- /dev/null
+++ 
b/gateway-provider-security-k8s/src/main/java/org/apache/knox/gateway/preauth/k8s/deploy/K8sPreAuthContributor.java
@@ -0,0 +1,66 @@
+/*
+ * 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.knox.gateway.preauth.k8s.deploy;
+
+import org.apache.knox.gateway.deploy.DeploymentContext;
+import org.apache.knox.gateway.deploy.ProviderDeploymentContributorBase;
+import org.apache.knox.gateway.descriptor.FilterParamDescriptor;
+import org.apache.knox.gateway.descriptor.ResourceDescriptor;
+import org.apache.knox.gateway.preauth.filter.PreAuthService;
+import org.apache.knox.gateway.preauth.k8s.ServiceAccountValidator;
+import org.apache.knox.gateway.topology.Provider;
+import org.apache.knox.gateway.topology.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Map.Entry;
+
+public class K8sPreAuthContributor extends ProviderDeploymentContributorBase {
+    private static final String ROLE = "federation";
+    private static final String NAME = "K8sPreAuth";
+    private static final String FILTER_CLASSNAME =
+            "org.apache.knox.gateway.preauth.k8s.K8sPreAuthFederationFilter";
+
+    @Override
+    public String getRole() {
+        return ROLE;
+    }
+
+    @Override
+    public String getName() {
+        return NAME;
+    }
+
+    @Override
+    public void contributeFilter(DeploymentContext context, Provider provider, 
Service service,
+                                 ResourceDescriptor resource, 
List<FilterParamDescriptor> params) {
+        if (params == null) {
+            params = new ArrayList<>();
+        }
+        Map<String, String> providerParams = provider.getParams();
+        for(Entry<String, String> entry : providerParams.entrySet()) {
+            params.add( resource.createFilterParam().name( 
entry.getKey().toLowerCase(Locale.ROOT) ).value( entry.getValue() ) );
+        }
+        params.add(resource.createFilterParam()
+                .name(PreAuthService.VALIDATION_METHOD_PARAM)
+                .value(ServiceAccountValidator.VALIDATION_METHOD_VALUE));
+        resource.addFilter().name( getName() ).role( getRole() ).impl( 
FILTER_CLASSNAME ).params( params );
+    }
+}
diff --git 
a/gateway-provider-security-k8s/src/main/resources/META-INF/services/org.apache.knox.gateway.deploy.ProviderDeploymentContributor
 
b/gateway-provider-security-k8s/src/main/resources/META-INF/services/org.apache.knox.gateway.deploy.ProviderDeploymentContributor
new file mode 100644
index 000000000..079f3044e
--- /dev/null
+++ 
b/gateway-provider-security-k8s/src/main/resources/META-INF/services/org.apache.knox.gateway.deploy.ProviderDeploymentContributor
@@ -0,0 +1,19 @@
+##########################################################################
+# 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.
+##########################################################################
+
+org.apache.knox.gateway.preauth.k8s.deploy.K8sPreAuthContributor
diff --git 
a/gateway-provider-security-k8s/src/main/resources/META-INF/services/org.apache.knox.gateway.preauth.filter.PreAuthValidator
 
b/gateway-provider-security-k8s/src/main/resources/META-INF/services/org.apache.knox.gateway.preauth.filter.PreAuthValidator
new file mode 100644
index 000000000..ef7d219af
--- /dev/null
+++ 
b/gateway-provider-security-k8s/src/main/resources/META-INF/services/org.apache.knox.gateway.preauth.filter.PreAuthValidator
@@ -0,0 +1,19 @@
+##########################################################################
+# 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.
+##########################################################################
+
+org.apache.knox.gateway.preauth.k8s.ServiceAccountValidator
diff --git 
a/gateway-provider-security-k8s/src/test/java/org/apache/knox/gateway/preauth/k8s/K8sPreAuthFederationFilterTest.java
 
b/gateway-provider-security-k8s/src/test/java/org/apache/knox/gateway/preauth/k8s/K8sPreAuthFederationFilterTest.java
new file mode 100644
index 000000000..9c2129a13
--- /dev/null
+++ 
b/gateway-provider-security-k8s/src/test/java/org/apache/knox/gateway/preauth/k8s/K8sPreAuthFederationFilterTest.java
@@ -0,0 +1,252 @@
+/*
+ * 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.knox.gateway.preauth.k8s;
+
+import org.apache.knox.gateway.preauth.filter.PreAuthService;
+import org.apache.knox.gateway.preauth.filter.PreAuthValidator;
+import org.easymock.EasyMock;
+import org.junit.Test;
+
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.time.Duration;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+public class K8sPreAuthFederationFilterTest {
+
+    @Test
+    public void testInitResolvesK8sValidatorFromContributorInjectedParam() 
throws ServletException {
+        TestableFilter filter = new TestableFilter();
+        FilterConfig cfg = niceCfg();
+        filter.init(cfg);
+
+        List<PreAuthValidator> validators = filter.getValidators();
+        assertEquals(1, validators.size());
+        assertEquals(ServiceAccountValidator.VALIDATION_METHOD_VALUE, 
validators.get(0).getName());
+        assertNotNull("init must construct a resolver", 
filter.lastCreatedResolver);
+    }
+
+    @Test
+    public void testInitRejectsNonPositiveTtl() {
+        TestableFilter filter = new TestableFilter();
+        FilterConfig cfg = 
niceCfg(ServiceAccountValidator.CACHE_TTL_SECONDS_PARAM, "0");
+        try {
+            filter.init(cfg);
+            fail("expected ServletException for ttl=0");
+        } catch (ServletException expected) {
+            assertTrue(expected.getMessage().contains(
+                    ServiceAccountValidator.CACHE_TTL_SECONDS_PARAM));
+        }
+        assertNull("resolver must not be created on bad config", 
filter.lastCreatedResolver);
+    }
+
+    @Test
+    public void testInitRejectsNegativeMaxSize() {
+        TestableFilter filter = new TestableFilter();
+        FilterConfig cfg = 
niceCfg(ServiceAccountValidator.CACHE_MAX_SIZE_PARAM, "-1");
+        try {
+            filter.init(cfg);
+            fail("expected ServletException for maxSize=-1");
+        } catch (ServletException expected) {
+            assertTrue(expected.getMessage().contains(
+                    ServiceAccountValidator.CACHE_MAX_SIZE_PARAM));
+        }
+        assertNull("resolver must not be created on bad config", 
filter.lastCreatedResolver);
+    }
+
+    @Test
+    public void testGetPrimaryPrincipalUsesDefaultUserHeaderWhenUnset() throws 
ServletException {
+        TestableFilter filter = new TestableFilter();
+        filter.init(niceCfg());
+
+        HttpServletRequest req = 
EasyMock.createNiceMock(HttpServletRequest.class);
+        
EasyMock.expect(req.getHeader(ServiceAccountValidator.USER_HEADER_DEFAULT))
+                .andReturn("alice").anyTimes();
+        EasyMock.replay(req);
+
+        assertEquals("alice", filter.getPrimaryPrincipal(req));
+    }
+
+    @Test
+    public void testGetPrimaryPrincipalHonorsCustomUserHeader() throws 
ServletException {
+        TestableFilter filter = new TestableFilter();
+        filter.init(niceCfg(ServiceAccountValidator.USER_HEADER_PARAM, 
"X-My-User"));
+
+        HttpServletRequest req = 
EasyMock.createNiceMock(HttpServletRequest.class);
+        
EasyMock.expect(req.getHeader("X-My-User")).andReturn("bob").anyTimes();
+        EasyMock.replay(req);
+
+        assertEquals("bob", filter.getPrimaryPrincipal(req));
+    }
+
+    @Test
+    public void testEmptyCustomUserHeaderFallsBackToDefault() throws 
ServletException {
+        TestableFilter filter = new TestableFilter();
+        filter.init(niceCfg(ServiceAccountValidator.USER_HEADER_PARAM, ""));
+
+        HttpServletRequest req = 
EasyMock.createNiceMock(HttpServletRequest.class);
+        
EasyMock.expect(req.getHeader(ServiceAccountValidator.USER_HEADER_DEFAULT))
+                .andReturn("carol").anyTimes();
+        EasyMock.replay(req);
+
+        assertEquals("carol", filter.getPrimaryPrincipal(req));
+    }
+
+    @Test
+    public void testGetPrimaryPrincipalReturnsNullWhenHeaderAbsent() throws 
ServletException {
+        TestableFilter filter = new TestableFilter();
+        filter.init(niceCfg());
+
+        HttpServletRequest req = 
EasyMock.createNiceMock(HttpServletRequest.class);
+        
EasyMock.expect(req.getHeader(ServiceAccountValidator.USER_HEADER_DEFAULT))
+                .andReturn(null).anyTimes();
+        EasyMock.replay(req);
+
+        assertNull(filter.getPrimaryPrincipal(req));
+    }
+
+    @Test
+    public void testDoFilterBindsResolverAttributeAndRemovesItAfterChain() 
throws Exception {
+        TestableFilter filter = new TestableFilter();
+        filter.init(niceCfg());
+
+        HttpServletRequest req = EasyMock.createMock(HttpServletRequest.class);
+        
req.setAttribute(EasyMock.eq(ServiceAccountValidator.RESOLVER_REQUEST_ATTR),
+                EasyMock.same(filter.lastCreatedResolver));
+        EasyMock.expectLastCall();
+        
EasyMock.expect(req.getHeader(ServiceAccountValidator.USER_HEADER_DEFAULT))
+                .andReturn(null);
+        req.removeAttribute(ServiceAccountValidator.RESOLVER_REQUEST_ATTR);
+        EasyMock.expectLastCall();
+        EasyMock.replay(req);
+
+        HttpServletResponse resp = 
EasyMock.createMock(HttpServletResponse.class);
+        resp.sendError(EasyMock.eq(HttpServletResponse.SC_FORBIDDEN), 
EasyMock.anyString());
+        EasyMock.expectLastCall();
+        EasyMock.replay(resp);
+
+        FilterChain chain = EasyMock.createMock(FilterChain.class);
+        EasyMock.replay(chain);
+
+        filter.doFilter(req, resp, chain);
+
+        EasyMock.verify(req, resp, chain);
+    }
+
+    @Test
+    public void testDoFilterRemovesResolverAttributeEvenIfChainThrows() throws 
Exception {
+        TestableFilter filter = new TestableFilter();
+        filter.init(niceCfg());
+
+        HttpServletRequest req = EasyMock.createMock(HttpServletRequest.class);
+        
req.setAttribute(EasyMock.eq(ServiceAccountValidator.RESOLVER_REQUEST_ATTR),
+                EasyMock.same(filter.lastCreatedResolver));
+        EasyMock.expectLastCall();
+        
EasyMock.expect(req.getHeader(ServiceAccountValidator.USER_HEADER_DEFAULT))
+                .andThrow(new RuntimeException("boom"));
+        req.removeAttribute(ServiceAccountValidator.RESOLVER_REQUEST_ATTR);
+        EasyMock.expectLastCall();
+        EasyMock.replay(req);
+
+        HttpServletResponse resp = 
EasyMock.createMock(HttpServletResponse.class);
+        EasyMock.replay(resp);
+        FilterChain chain = EasyMock.createMock(FilterChain.class);
+        EasyMock.replay(chain);
+
+        try {
+            filter.doFilter(req, resp, chain);
+            fail("expected RuntimeException to propagate");
+        } catch (RuntimeException expected) {
+            assertEquals("boom", expected.getMessage());
+        }
+        EasyMock.verify(req, resp, chain);
+    }
+
+    @Test
+    public void testDestroyClosesResolverOnce() throws ServletException {
+        K8sServiceAccountResolver resolver = 
EasyMock.createMock(K8sServiceAccountResolver.class);
+        resolver.close();
+        EasyMock.expectLastCall().once();
+        EasyMock.replay(resolver);
+
+        TestableFilter filter = new TestableFilter(resolver);
+        filter.init(niceCfg());
+        assertSame(resolver, filter.lastCreatedResolver);
+
+        filter.destroy();
+        // Second destroy must not double-close.
+        filter.destroy();
+
+        EasyMock.verify(resolver);
+    }
+
+    @Test
+    public void testDestroyWithoutInitDoesNotThrow() {
+        K8sPreAuthFederationFilter filter = new K8sPreAuthFederationFilter();
+        filter.destroy();
+    }
+
+    private static FilterConfig niceCfg() {
+        FilterConfig cfg = EasyMock.createNiceMock(FilterConfig.class);
+        
EasyMock.expect(cfg.getInitParameter(PreAuthService.VALIDATION_METHOD_PARAM))
+                
.andReturn(ServiceAccountValidator.VALIDATION_METHOD_VALUE).anyTimes();
+        EasyMock.replay(cfg);
+        return cfg;
+    }
+
+    private static FilterConfig niceCfg(String paramName, String paramValue) {
+        FilterConfig cfg = EasyMock.createNiceMock(FilterConfig.class);
+        
EasyMock.expect(cfg.getInitParameter(PreAuthService.VALIDATION_METHOD_PARAM))
+                
.andReturn(ServiceAccountValidator.VALIDATION_METHOD_VALUE).anyTimes();
+        
EasyMock.expect(cfg.getInitParameter(paramName)).andReturn(paramValue).anyTimes();
+        EasyMock.replay(cfg);
+        return cfg;
+    }
+
+    private static final class TestableFilter extends 
K8sPreAuthFederationFilter {
+        private final K8sServiceAccountResolver fixed;
+        K8sServiceAccountResolver lastCreatedResolver;
+
+        TestableFilter() {
+            this(null);
+        }
+
+        TestableFilter(K8sServiceAccountResolver fixed) {
+            this.fixed = fixed;
+        }
+
+        @Override
+        protected K8sServiceAccountResolver createResolver(Duration ttl, long 
maxSize) {
+            K8sServiceAccountResolver r = fixed != null
+                    ? fixed
+                    : EasyMock.createNiceMock(K8sServiceAccountResolver.class);
+            lastCreatedResolver = r;
+            return r;
+        }
+    }
+}
diff --git 
a/gateway-provider-security-k8s/src/test/java/org/apache/knox/gateway/preauth/k8s/K8sServiceAccountResolverTest.java
 
b/gateway-provider-security-k8s/src/test/java/org/apache/knox/gateway/preauth/k8s/K8sServiceAccountResolverTest.java
new file mode 100644
index 000000000..bc8da8cc2
--- /dev/null
+++ 
b/gateway-provider-security-k8s/src/test/java/org/apache/knox/gateway/preauth/k8s/K8sServiceAccountResolverTest.java
@@ -0,0 +1,214 @@
+/*
+ * 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.knox.gateway.preauth.k8s;
+
+import com.github.benmanes.caffeine.cache.Ticker;
+import io.fabric8.kubernetes.api.model.ObjectMeta;
+import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
+import io.fabric8.kubernetes.api.model.ServiceAccount;
+import io.fabric8.kubernetes.api.model.ServiceAccountBuilder;
+import io.fabric8.kubernetes.api.model.ServiceAccountList;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.KubernetesClientException;
+import io.fabric8.kubernetes.client.dsl.MixedOperation;
+import io.fabric8.kubernetes.client.dsl.NonNamespaceOperation;
+import io.fabric8.kubernetes.client.dsl.ServiceAccountResource;
+import org.easymock.EasyMock;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicLong;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+
+public class K8sServiceAccountResolverTest {
+
+    private static final String NS = "demo";
+    private static final String SA = "demo-app";
+    private static final String ANNOTATION = "knox.apache.org/owner-username";
+
+    private KubernetesClient client;
+    private final MixedOperation<ServiceAccount, ServiceAccountList, 
ServiceAccountResource> mixed =
+            EasyMock.createMock(MixedOperation.class);
+    private final NonNamespaceOperation<ServiceAccount, ServiceAccountList, 
ServiceAccountResource> namespaced =
+            EasyMock.createMock(NonNamespaceOperation.class);
+    private ServiceAccountResource resource;
+
+    @Before
+    public void setUp() {
+        client = EasyMock.createMock(KubernetesClient.class);
+        resource = EasyMock.createMock(ServiceAccountResource.class);
+    }
+
+    @Test
+    public void testReturnAnnotationWhenPresent() {
+        expectFetchReturning(saWithAnnotations(Map.of(ANNOTATION, "bob")));
+        replayAll();
+
+        K8sServiceAccountResolver r = new K8sServiceAccountResolver(client, 
Duration.ofSeconds(60), 100);
+        assertEquals(Optional.of("bob"), r.getAnnotation(NS, SA, ANNOTATION));
+        verifyAll();
+    }
+
+    @Test
+    public void testReturnEmptyWhenAnnotationMissingFromExistingSA() {
+        expectFetchReturning(saWithAnnotations(Map.of("other-key", "x")));
+        replayAll();
+
+        K8sServiceAccountResolver r = new K8sServiceAccountResolver(client, 
Duration.ofSeconds(60), 100);
+        assertFalse(r.getAnnotation(NS, SA, ANNOTATION).isPresent());
+        verifyAll();
+    }
+
+    @Test
+    public void testCacheSuccessfulLookups() {
+        expectFetchReturning(saWithAnnotations(Map.of(ANNOTATION, "bob")));
+        replayAll();
+
+        K8sServiceAccountResolver r = new K8sServiceAccountResolver(client, 
Duration.ofSeconds(60), 100);
+        assertEquals(Optional.of("bob"), r.getAnnotation(NS, SA, ANNOTATION));
+        assertEquals(Optional.of("bob"), r.getAnnotation(NS, SA, ANNOTATION));
+        verifyAll();
+    }
+
+    @Test
+    public void testCacheSAAbsenceAsEmpty() {
+        expectFetchReturning(null);
+        replayAll();
+
+        K8sServiceAccountResolver r = new K8sServiceAccountResolver(client, 
Duration.ofSeconds(60), 100);
+        assertFalse(r.getAnnotation(NS, SA, ANNOTATION).isPresent());
+        assertFalse(r.getAnnotation(NS, SA, ANNOTATION).isPresent());
+        verifyAll();
+    }
+
+    @Test
+    public void testCacheSAWithoutAnnotationsAsEmpty() {
+        expectFetchReturning(saWithAnnotations(null));
+        replayAll();
+
+        K8sServiceAccountResolver r = new K8sServiceAccountResolver(client, 
Duration.ofSeconds(60), 100);
+        assertFalse(r.getAnnotation(NS, SA, ANNOTATION).isPresent());
+        assertFalse(r.getAnnotation(NS, SA, ANNOTATION).isPresent());
+        verifyAll();
+    }
+
+    @Test
+    public void testCacheSAWithoutMetadataAsEmpty() {
+        expectFetchReturning(new ServiceAccountBuilder().build()); // no 
metadata
+        replayAll();
+
+        K8sServiceAccountResolver r = new K8sServiceAccountResolver(client, 
Duration.ofSeconds(60), 100);
+        assertFalse(r.getAnnotation(NS, SA, ANNOTATION).isPresent());
+        assertFalse(r.getAnnotation(NS, SA, ANNOTATION).isPresent());
+        verifyAll();
+    }
+
+    @Test
+    public void testApiFailureIsNotCached() {
+        EasyMock.expect(client.serviceAccounts()).andReturn(mixed).times(2);
+        EasyMock.expect(mixed.inNamespace(NS)).andReturn(namespaced).times(2);
+        EasyMock.expect(namespaced.withName(SA)).andReturn(resource).times(2);
+        EasyMock.expect(resource.get())
+                .andThrow(new KubernetesClientException("503 boom"))
+                .andReturn(saWithAnnotations(Map.of(ANNOTATION, "bob")));
+        replayAll();
+
+        K8sServiceAccountResolver r = new K8sServiceAccountResolver(client, 
Duration.ofSeconds(60), 100);
+        assertFalse(r.getAnnotation(NS, SA, ANNOTATION).isPresent());
+        assertEquals(Optional.of("bob"), r.getAnnotation(NS, SA, ANNOTATION));
+        verifyAll();
+    }
+
+    @Test
+    public void testDifferentAnnotationKeysOnSameSAShareSingleFetch() {
+        Map<String, String> annotations = new HashMap<>();
+        annotations.put(ANNOTATION, "bob");
+        annotations.put("other-key", "value-2");
+        expectFetchReturning(saWithAnnotations(annotations));
+        replayAll();
+
+        K8sServiceAccountResolver r = new K8sServiceAccountResolver(client, 
Duration.ofSeconds(60), 100);
+        assertEquals(Optional.of("bob"), r.getAnnotation(NS, SA, ANNOTATION));
+        assertEquals(Optional.of("value-2"), r.getAnnotation(NS, SA, 
"other-key"));
+        verifyAll();
+    }
+
+    @Test
+    public void testCacheEntryExpiresAfterTtl() {
+        EasyMock.expect(client.serviceAccounts()).andReturn(mixed).times(2);
+        EasyMock.expect(mixed.inNamespace(NS)).andReturn(namespaced).times(2);
+        EasyMock.expect(namespaced.withName(SA)).andReturn(resource).times(2);
+        EasyMock.expect(resource.get())
+                .andReturn(saWithAnnotations(Map.of(ANNOTATION, "bob")))
+                .andReturn(saWithAnnotations(Map.of(ANNOTATION, "alice")));
+        replayAll();
+
+        FakeTicker ticker = new FakeTicker();
+        K8sServiceAccountResolver r =
+                new K8sServiceAccountResolver(client, Duration.ofSeconds(10), 
100, ticker);
+
+        assertEquals(Optional.of("bob"), r.getAnnotation(NS, SA, ANNOTATION));
+        assertEquals(Optional.of("bob"), r.getAnnotation(NS, SA, ANNOTATION));
+        ticker.advance(Duration.ofSeconds(11));
+        assertEquals(Optional.of("alice"), r.getAnnotation(NS, SA, 
ANNOTATION));
+        verifyAll();
+    }
+
+    private void expectFetchReturning(ServiceAccount sa) {
+        EasyMock.expect(client.serviceAccounts()).andReturn(mixed);
+        EasyMock.expect(mixed.inNamespace(NS)).andReturn(namespaced);
+        EasyMock.expect(namespaced.withName(SA)).andReturn(resource);
+        EasyMock.expect(resource.get()).andReturn(sa);
+    }
+
+    private static ServiceAccount saWithAnnotations(Map<String, String> 
annotations) {
+        ObjectMeta meta = new ObjectMetaBuilder()
+                .withNamespace(NS)
+                .withName(SA)
+                .withAnnotations(annotations)
+                .build();
+        return new ServiceAccountBuilder().withMetadata(meta).build();
+    }
+
+    private void replayAll() {
+        EasyMock.replay(client, mixed, namespaced, resource);
+    }
+
+    private void verifyAll() {
+        EasyMock.verify(client, mixed, namespaced, resource);
+    }
+
+    private static final class FakeTicker implements Ticker {
+        private final AtomicLong nanos = new AtomicLong(0);
+
+        void advance(Duration d) {
+            nanos.addAndGet(d.toNanos());
+        }
+
+        @Override
+        public long read() {
+            return nanos.get();
+        }
+    }
+}
diff --git 
a/gateway-provider-security-k8s/src/test/java/org/apache/knox/gateway/preauth/k8s/ServiceAccountValidatorTest.java
 
b/gateway-provider-security-k8s/src/test/java/org/apache/knox/gateway/preauth/k8s/ServiceAccountValidatorTest.java
new file mode 100644
index 000000000..3c23b22a8
--- /dev/null
+++ 
b/gateway-provider-security-k8s/src/test/java/org/apache/knox/gateway/preauth/k8s/ServiceAccountValidatorTest.java
@@ -0,0 +1,151 @@
+/*
+ * 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.knox.gateway.preauth.k8s;
+
+import org.apache.knox.gateway.preauth.filter.PreAuthValidationException;
+import org.easymock.EasyMock;
+import org.junit.Before;
+import org.junit.Test;
+
+import javax.servlet.FilterConfig;
+import javax.servlet.http.HttpServletRequest;
+import java.util.Optional;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+public class ServiceAccountValidatorTest {
+    private static final String SPIFFE = 
"spiffe://cluster.local/ns/demo/sa/demo-app";
+    private static final String NS = "demo";
+    private static final String SA = "demo-app";
+    private static final String ANNOTATION = "knox.apache.org/owner-username";
+
+    private K8sServiceAccountResolver resolver;
+    private ServiceAccountValidator validator;
+
+    @Before
+    public void setUp() {
+        resolver = EasyMock.createMock(K8sServiceAccountResolver.class);
+        validator = new ServiceAccountValidator();
+    }
+
+    @Test
+    public void testName() {
+        assertEquals(ServiceAccountValidator.VALIDATION_METHOD_VALUE, 
validator.getName());
+    }
+
+    @Test
+    public void testAcceptWhenAnnotationMatchesUserHeader() throws 
PreAuthValidationException {
+        EasyMock.expect(resolver.getAnnotation(NS, SA, 
ANNOTATION)).andReturn(Optional.of("bob"));
+        EasyMock.replay(resolver);
+
+        assertTrue(validator.validate(request(SPIFFE, "bob", resolver), 
defaultConfig()));
+        EasyMock.verify(resolver);
+    }
+
+    @Test
+    public void testRejectWhenAnnotationDiffersFromUserHeader() throws 
PreAuthValidationException {
+        EasyMock.expect(resolver.getAnnotation(NS, SA, 
ANNOTATION)).andReturn(Optional.of("alice"));
+        EasyMock.replay(resolver);
+
+        assertFalse(validator.validate(request(SPIFFE, "bob", resolver), 
defaultConfig()));
+        EasyMock.verify(resolver);
+    }
+
+    @Test
+    public void testRejectWhenSpiffeHeaderMissing() throws 
PreAuthValidationException {
+        EasyMock.replay(resolver);
+        assertFalse(validator.validate(request(null, "bob", resolver), 
defaultConfig()));
+        EasyMock.verify(resolver);
+    }
+
+    @Test
+    public void testRejectWhenUserHeaderMissing() throws 
PreAuthValidationException {
+        EasyMock.replay(resolver);
+        assertFalse(validator.validate(request(SPIFFE, null, resolver), 
defaultConfig()));
+        EasyMock.verify(resolver);
+    }
+
+    @Test
+    public void testRejectWhenSpiffeUnparseable() throws 
PreAuthValidationException {
+        EasyMock.replay(resolver);
+        assertFalse(validator.validate(request("not-a-spiffe-id", "bob", 
resolver), defaultConfig()));
+        EasyMock.verify(resolver);
+    }
+
+    @Test
+    public void testRejectWhenServiceAccountAnnotationMissing() throws 
PreAuthValidationException {
+        EasyMock.expect(resolver.getAnnotation(NS, SA, 
ANNOTATION)).andReturn(Optional.empty());
+        EasyMock.replay(resolver);
+
+        assertFalse(validator.validate(request(SPIFFE, "bob", resolver), 
defaultConfig()));
+        EasyMock.verify(resolver);
+    }
+
+    @Test
+    public void testHonorCustomHeaderAndAnnotationConfig() throws 
PreAuthValidationException {
+        final String customAnnotation = "example.com/owner-username";
+        EasyMock.expect(resolver.getAnnotation(NS, SA, 
customAnnotation)).andReturn(Optional.of("bob"));
+        EasyMock.replay(resolver);
+
+        final FilterConfig cfg = EasyMock.createMock(FilterConfig.class);
+        
EasyMock.expect(cfg.getInitParameter(ServiceAccountValidator.SPIFFE_HEADER_PARAM))
+                .andReturn("X-Custom-Spiffe").anyTimes();
+        
EasyMock.expect(cfg.getInitParameter(ServiceAccountValidator.USER_HEADER_PARAM))
+                .andReturn("x-custom-user").anyTimes();
+        
EasyMock.expect(cfg.getInitParameter(ServiceAccountValidator.USER_ANNOTATION_PARAM))
+                .andReturn(customAnnotation).anyTimes();
+        EasyMock.replay(cfg);
+
+        final HttpServletRequest req = 
EasyMock.createMock(HttpServletRequest.class);
+        
EasyMock.expect(req.getAttribute(ServiceAccountValidator.RESOLVER_REQUEST_ATTR))
+                .andReturn(resolver).anyTimes();
+        
EasyMock.expect(req.getHeader("X-Custom-Spiffe")).andReturn(SPIFFE).anyTimes();
+        
EasyMock.expect(req.getHeader("x-custom-user")).andReturn("bob").anyTimes();
+        EasyMock.replay(req);
+
+        assertTrue(validator.validate(req, cfg));
+        EasyMock.verify(resolver, cfg, req);
+    }
+
+    private static FilterConfig defaultConfig() {
+        final FilterConfig cfg = EasyMock.createMock(FilterConfig.class);
+        
EasyMock.expect(cfg.getInitParameter(ServiceAccountValidator.SPIFFE_HEADER_PARAM))
+                .andReturn(null).anyTimes();
+        
EasyMock.expect(cfg.getInitParameter(ServiceAccountValidator.USER_HEADER_PARAM))
+                .andReturn(null).anyTimes();
+        
EasyMock.expect(cfg.getInitParameter(ServiceAccountValidator.USER_ANNOTATION_PARAM))
+                .andReturn(null).anyTimes();
+        EasyMock.replay(cfg);
+        return cfg;
+    }
+
+    private static HttpServletRequest request(String spiffe, String user,
+                                              K8sServiceAccountResolver 
resolver) {
+        final HttpServletRequest req = 
EasyMock.createMock(HttpServletRequest.class);
+        
EasyMock.expect(req.getAttribute(ServiceAccountValidator.RESOLVER_REQUEST_ATTR))
+                .andReturn(resolver).anyTimes();
+        
EasyMock.expect(req.getHeader(ServiceAccountValidator.SPIFFE_HEADER_DEFAULT))
+                .andReturn(spiffe).anyTimes();
+        
EasyMock.expect(req.getHeader(ServiceAccountValidator.USER_HEADER_DEFAULT))
+                .andReturn(user).anyTimes();
+        EasyMock.replay(req);
+        return req;
+    }
+}
diff --git 
a/gateway-provider-security-k8s/src/test/java/org/apache/knox/gateway/preauth/k8s/SpiffeIdTest.java
 
b/gateway-provider-security-k8s/src/test/java/org/apache/knox/gateway/preauth/k8s/SpiffeIdTest.java
new file mode 100644
index 000000000..55689ffa8
--- /dev/null
+++ 
b/gateway-provider-security-k8s/src/test/java/org/apache/knox/gateway/preauth/k8s/SpiffeIdTest.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.knox.gateway.preauth.k8s;
+
+import org.junit.Test;
+
+import java.util.Optional;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+public class SpiffeIdTest {
+    @Test
+    public void testParseValidId() {
+        Optional<SpiffeId> parsed = 
SpiffeId.parse("spiffe://cluster.local/ns/demo/sa/demo-app");
+        assertTrue(parsed.isPresent());
+        assertEquals("cluster.local", parsed.get().trustDomain());
+        assertEquals("demo", parsed.get().namespace());
+        assertEquals("demo-app", parsed.get().serviceAccount());
+    }
+
+    @Test
+    public void testRejectNullAndEmpty() {
+        assertFalse(SpiffeId.parse(null).isPresent());
+        assertFalse(SpiffeId.parse("").isPresent());
+        assertFalse(SpiffeId.parse("   ").isPresent());
+    }
+
+    @Test
+    public void testRejectWrongScheme() {
+        
assertFalse(SpiffeId.parse("https://cluster.local/ns/demo/sa/foo";).isPresent());
+    }
+
+    @Test
+    public void testRejectMissingNsOrSaSegments() {
+        
assertFalse(SpiffeId.parse("spiffe://cluster.local/demo/sa/foo").isPresent());
+        
assertFalse(SpiffeId.parse("spiffe://cluster.local/ns/demo/foo").isPresent());
+        
assertFalse(SpiffeId.parse("spiffe://cluster.local/ns/demo").isPresent());
+        assertFalse(SpiffeId.parse("spiffe://cluster.local").isPresent());
+    }
+
+    @Test
+    public void testRejectExtraPathSegments() {
+        
assertFalse(SpiffeId.parse("spiffe://cluster.local/ns/demo/sa/foo/extra").isPresent());
+    }
+
+    @Test
+    public void testRejectEmptyNamespaceOrServiceAccount() {
+        
assertFalse(SpiffeId.parse("spiffe://cluster.local/ns//sa/foo").isPresent());
+        
assertFalse(SpiffeId.parse("spiffe://cluster.local/ns/demo/sa/").isPresent());
+    }
+
+    @Test
+    public void testRejectMalformedUri() {
+        assertFalse(SpiffeId.parse("spiffe:// 
cluster.local/ns/demo/sa/foo").isPresent());
+    }
+}
diff --git 
a/gateway-provider-security-preauth/src/main/java/org/apache/knox/gateway/preauth/filter/AbstractPreAuthFederationFilter.java
 
b/gateway-provider-security-preauth/src/main/java/org/apache/knox/gateway/preauth/filter/AbstractPreAuthFederationFilter.java
index 4c3399fcc..d04a0d93c 100644
--- 
a/gateway-provider-security-preauth/src/main/java/org/apache/knox/gateway/preauth/filter/AbstractPreAuthFederationFilter.java
+++ 
b/gateway-provider-security-preauth/src/main/java/org/apache/knox/gateway/preauth/filter/AbstractPreAuthFederationFilter.java
@@ -87,13 +87,21 @@ public abstract class AbstractPreAuthFederationFilter 
implements Filter {
         doAs(httpRequest, response, chain, subject);
       } else {
         // TODO: log preauthenticated SSO validation failure
-        
((HttpServletResponse)response).sendError(HttpServletResponse.SC_FORBIDDEN, 
"SSO Validation Failure.");
+        
((HttpServletResponse)response).sendError(HttpServletResponse.SC_FORBIDDEN, 
getValidationFailureMessage());
       }
     } else {
-      
((HttpServletResponse)response).sendError(HttpServletResponse.SC_FORBIDDEN, 
"Missing Required Header for PreAuth SSO Federation");
+      
((HttpServletResponse)response).sendError(HttpServletResponse.SC_FORBIDDEN, 
getMissingPrincipalMessage());
     }
   }
 
+  protected String getValidationFailureMessage() {
+    return "SSO Validation Failure.";
+  }
+
+  protected String getMissingPrincipalMessage() {
+    return "Missing Required Header for PreAuth SSO Federation";
+  }
+
   @Override
   public void destroy() {
   }
diff --git a/gateway-release/pom.xml b/gateway-release/pom.xml
index 391c1a25b..1382430f8 100644
--- a/gateway-release/pom.xml
+++ b/gateway-release/pom.xml
@@ -381,6 +381,10 @@
             <groupId>org.apache.knox</groupId>
             <artifactId>gateway-provider-security-preauth</artifactId>
         </dependency>
+        <dependency>
+            <groupId>org.apache.knox</groupId>
+            <artifactId>gateway-provider-security-k8s</artifactId>
+        </dependency>
         <dependency>
             <groupId>org.apache.knox</groupId>
             <artifactId>gateway-provider-security-hadoopauth</artifactId>
diff --git a/pom.xml b/pom.xml
index 9bb9a2abc..60f987d8a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -87,6 +87,7 @@
         <module>gateway-provider-security-jwt</module>
         <module>gateway-provider-security-webappsec</module>
         <module>gateway-provider-security-preauth</module>
+        <module>gateway-provider-security-k8s</module>
         <module>gateway-provider-security-hadoopauth</module>
         <module>gateway-provider-security-clientcert</module>
         <module>gateway-provider-security-shiro</module>
@@ -201,9 +202,9 @@
         <ehcache.version>3.3.1</ehcache.version>
         <exec-maven-plugin.version>3.0.0</exec-maven-plugin.version>
         <fastinfoset.version>1.2.18</fastinfoset.version>
-        <findsecbugs-plugin.version>1.11.0</findsecbugs-plugin.version>
         <forbiddenapis.version>3.9</forbiddenapis.version>
         <findsecbugs-plugin.version>1.11.0</findsecbugs-plugin.version>
+        
<fabric8.kubernetes-client.version>7.7.0</fabric8.kubernetes-client.version>
         <frontend-maven-plugin.version>1.15.1</frontend-maven-plugin.version>
         <gethostname4j.version>1.0.0</gethostname4j.version>  <!-- See here: 
https://github.com/mattsheppard/gethostname4j -->
         <glassfish-jaxb.version>4.0.5</glassfish-jaxb.version>
@@ -1046,6 +1047,11 @@
                 <artifactId>gateway-provider-security-preauth</artifactId>
                 <version>${project.version}</version>
             </dependency>
+            <dependency>
+                <groupId>org.apache.knox</groupId>
+                <artifactId>gateway-provider-security-k8s</artifactId>
+                <version>${project.version}</version>
+            </dependency>
             <dependency>
                 <groupId>org.apache.knox</groupId>
                 <artifactId>gateway-provider-security-hadoopauth</artifactId>
@@ -2765,6 +2771,21 @@
                     </exclusion>
                 </exclusions>
             </dependency>
+            <dependency>
+                <groupId>io.fabric8</groupId>
+                <artifactId>kubernetes-client</artifactId>
+                <version>${fabric8.kubernetes-client.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.fabric8</groupId>
+                <artifactId>kubernetes-client-api</artifactId>
+                <version>${fabric8.kubernetes-client.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.fabric8</groupId>
+                <artifactId>kubernetes-model-core</artifactId>
+                <version>${fabric8.kubernetes-client.version}</version>
+            </dependency>
             <dependency>
                 <groupId>io.fabric8</groupId>
                 <artifactId>docker-maven-plugin</artifactId>

Reply via email to