This is an automated email from the ASF dual-hosted git repository.
smolnar82 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/knox.git
The following commit(s) were added to refs/heads/master by this push:
new 755c4bc1f KNOX-3352: k8s pre-auth validator for service account
annotation (#1268)
755c4bc1f is described below
commit 755c4bc1f2c97a16d2f8d79f16a4db089f1ca075
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
---
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 c8b7c74d4..856cf3992 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 2cd80589c..26ac1b09d 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>
@@ -200,9 +201,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>
@@ -1044,6 +1045,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>
@@ -2748,6 +2754,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>