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>
