From 127d9bc2da2e1736508a49f2532ecb4a1b46a21b Mon Sep 17 00:00:00 2001 From: Harrison Date: Tue, 27 Oct 2026 23:19:10 -0700 Subject: [PATCH 1/5] KNOX-3534 - Add regression coverage for non-delegation token audience routing and early-rejection ordering Pins how K8sDestinationAudienceValidator routes a token by the presence or absence of its act claim, and the order in which its early rejections fire relative to one another. Several of those rejections differ only in their message, and nothing in the suite asserted which one wins when more than one applies. No production change. --- .../K8sNonDelegationTokenRoutingTest.java | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sNonDelegationTokenRoutingTest.java diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sNonDelegationTokenRoutingTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sNonDelegationTokenRoutingTest.java new file mode 100644 index 000000000..996bb0610 --- /dev/null +++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sNonDelegationTokenRoutingTest.java @@ -0,0 +1,224 @@ +/* + * 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.provider.federation.jwt.filter; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import jakarta.servlet.http.HttpServletRequest; + +import org.junit.Test; + +import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.delegationToken; +import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.filterConfig; +import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.nonDelegationToken; +import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.requestWithHeaders; + +/** + * Pins how a token is routed by the presence or absence of its {@code act} claim, and pins the + * order in which {@code validate()}'s early rejections fire relative to one another, since several + * of those rejections are distinguishable only by their message. + * + *

Every test configures the source SPIFFE id header, because the order in which that header is + * resolved and rejected relative to the other checks is part of what is pinned here. + */ +public class K8sNonDelegationTokenRoutingTest { + + private static final String SPIFFE_HEADER = "x-dest-spiffe-id"; + private static final String SOURCE_SPIFFE_HEADER = "x-source-spiffe-id"; + private static final String PATH_HEADER = "x-dest-path"; + private static final String CLUSTER_DOMAIN = "cluster.local"; + private static final String DEST_SPIFFE_VALUE = "spiffe://trust-domain/ns/ns/sa/dest-sa"; + private static final String SOURCE_SPIFFE_VALUE = "spiffe://trust-domain/ns/ns/sa/caller-sa"; + private static final String DEST_PATH_VALUE = "/some/path"; + private static final String MATCHING_AUD = "https://svc.ns"; + private static final String OTHER_NAMESPACE_AUD = "https://svc.other-ns"; + private static final String CONFIGURED_AUD = "configured-audience"; + private static final String MISSING_SOURCE_MESSAGE = "Missing or unparseable source SPIFFE id in header"; + private static final String MISSING_DESTINATION_MESSAGE = "Missing or unparseable destination SPIFFE id in header"; + + private static K8sDestinationAudienceValidator init(final Map params) throws Exception { + final K8sDestinationAudienceValidator validator = new K8sDestinationAudienceValidator(); + validator.init(filterConfig(params)); + return validator; + } + + private static Map sourceHeaderParams() { + final Map params = new HashMap<>(); + params.put(K8sDestinationAudienceValidator.NAMESPACE_FROM_SPIFFE_ID_HEADER_PARAM, SPIFFE_HEADER); + params.put(K8sDestinationAudienceValidator.SOURCE_SPIFFE_ID_HEADER_PARAM, SOURCE_SPIFFE_HEADER); + params.put(K8sDestinationAudienceValidator.CLUSTER_DOMAINS_PARAM, CLUSTER_DOMAIN); + return params; + } + + private static Map sourceHeaderAndPathParams() { + final Map params = sourceHeaderParams(); + params.put(K8sDestinationAudienceValidator.PATH_HEADER_PARAM, PATH_HEADER); + return params; + } + + private static Map fixedListFallbackParams() { + final Map params = sourceHeaderParams(); + params.put(K8sDestinationAudienceValidator.VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM, "false"); + return params; + } + + private static Map allHeaders() { + final Map headers = new HashMap<>(); + headers.put(SPIFFE_HEADER, DEST_SPIFFE_VALUE); + headers.put(SOURCE_SPIFFE_HEADER, SOURCE_SPIFFE_VALUE); + headers.put(PATH_HEADER, DEST_PATH_VALUE); + return headers; + } + + private static Map destinationHeaderOnly() { + final Map headers = new HashMap<>(); + headers.put(SPIFFE_HEADER, DEST_SPIFFE_VALUE); + return headers; + } + + // ---- routing by act claim, source header configured ---- + + @Test + public void testNonDelegationTokenWithSourceHeaderConfiguredIsDestinationValidated() throws Exception { + final K8sDestinationAudienceValidator validator = init(sourceHeaderAndPathParams()); + final HttpServletRequest request = requestWithHeaders(allHeaders()); + assertTrue(validator.validate(request, nonDelegationToken(MATCHING_AUD), null).isValid()); + } + + @Test + public void testNonDelegationTokenWithSourceHeaderConfiguredRejectsNonMatchingAud() throws Exception { + final K8sDestinationAudienceValidator validator = init(sourceHeaderAndPathParams()); + final HttpServletRequest request = requestWithHeaders(allHeaders()); + final AudienceValidationResult result = + validator.validate(request, nonDelegationToken(OTHER_NAMESPACE_AUD), null); + assertFalse(result.isValid()); + assertTrue(result.message().contains("does not match the destination")); + assertTrue(result.message().contains("namespace other-ns does not match destination namespace ns")); + } + + @Test + public void testDelegationTokenWithSourceHeaderConfiguredIsDestinationValidated() throws Exception { + final K8sDestinationAudienceValidator validator = init(sourceHeaderAndPathParams()); + final HttpServletRequest request = requestWithHeaders(allHeaders()); + assertTrue(validator.validate(request, delegationToken(MATCHING_AUD), null).isValid()); + assertFalse(validator.validate(request, delegationToken(OTHER_NAMESPACE_AUD), null).isValid()); + } + + // ---- fixed-list fallback, source header configured ---- + + @Test + public void testNonDelegationTokenFallsBackToWhitelistWhenValidateWithoutActClaimFalse() throws Exception { + final K8sDestinationAudienceValidator validator = init(fixedListFallbackParams()); + final HttpServletRequest request = requestWithHeaders(allHeaders()); + assertTrue(validator.validate(request, nonDelegationToken(CONFIGURED_AUD), + Collections.singletonList(CONFIGURED_AUD)).isValid()); + } + + @Test + public void testNonDelegationTokenWhitelistFallbackRejectsAudienceNotInList() throws Exception { + final K8sDestinationAudienceValidator validator = init(fixedListFallbackParams()); + final HttpServletRequest request = requestWithHeaders(allHeaders()); + final AudienceValidationResult result = validator.validate(request, nonDelegationToken("some-other-audience"), + Collections.singletonList(CONFIGURED_AUD)); + assertFalse(result.isValid()); + // Destination validation always explains a rejection; the fixed-list check does not. + assertNull(result.message()); + } + + @Test + public void testNonDelegationTokenWhitelistFallbackAcceptsWhenConfiguredAudiencesNull() throws Exception { + final K8sDestinationAudienceValidator validator = init(fixedListFallbackParams()); + final HttpServletRequest request = requestWithHeaders(allHeaders()); + assertTrue(validator.validate(request, nonDelegationToken("some-other-audience"), null).isValid()); + } + + @Test + public void testNonDelegationTokenWhitelistFallbackRejectsNoneSentinelWithEmptyAudClaim() throws Exception { + final K8sDestinationAudienceValidator validator = init(fixedListFallbackParams()); + final HttpServletRequest request = requestWithHeaders(allHeaders()); + // An empty aud array is not null, so the fixed-list check iterates zero entries and never + // reaches its NONE branch. + assertFalse(validator.validate(request, nonDelegationToken(), Collections.singletonList("NONE")).isValid()); + } + + @Test + public void testNonDelegationTokenWhitelistFallbackAcceptsNoneSentinelWithNullAudClaim() throws Exception { + final K8sDestinationAudienceValidator validator = init(fixedListFallbackParams()); + final HttpServletRequest request = requestWithHeaders(allHeaders()); + assertTrue(validator.validate(request, nonDelegationToken((String[]) null), + Collections.singletonList("NONE")).isValid()); + } + + // ---- order of early rejections ---- + + @Test + public void testDelegationTokenWithNoAudClaimRejectedBeforeSourceHeaderIsChecked() throws Exception { + final K8sDestinationAudienceValidator validator = init(sourceHeaderParams()); + final HttpServletRequest request = requestWithHeaders(destinationHeaderOnly()); + final AudienceValidationResult result = validator.validate(request, delegationToken((String[]) null), null); + assertFalse(result.isValid()); + assertEquals("Token has an act claim but no aud claim", result.message()); + } + + @Test + public void testNonDelegationTokenWithNoAudClaimRejectedBeforeSourceHeaderIsChecked() throws Exception { + final K8sDestinationAudienceValidator validator = init(sourceHeaderParams()); + final HttpServletRequest request = requestWithHeaders(destinationHeaderOnly()); + final AudienceValidationResult result = validator.validate(request, nonDelegationToken((String[]) null), null); + assertFalse(result.isValid()); + assertEquals("Token has no act claim and no aud claim, and " + + K8sDestinationAudienceValidator.VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM + " is true", + result.message()); + } + + @Test + public void testDestinationSpiffeHeaderRejectionPrecedesSourceSpiffeHeaderRejection() throws Exception { + final K8sDestinationAudienceValidator validator = init(sourceHeaderParams()); + final HttpServletRequest request = requestWithHeaders(new HashMap<>()); + final AudienceValidationResult result = validator.validate(request, delegationToken(MATCHING_AUD), null); + assertFalse(result.isValid()); + assertEquals(MISSING_DESTINATION_MESSAGE + " " + SPIFFE_HEADER + ": null", result.message()); + } + + @Test + public void testNonDelegationTokenWithMissingSourceHeaderIsRejected() throws Exception { + final K8sDestinationAudienceValidator validator = init(sourceHeaderParams()); + final HttpServletRequest request = requestWithHeaders(destinationHeaderOnly()); + final AudienceValidationResult result = validator.validate(request, nonDelegationToken(MATCHING_AUD), null); + assertFalse(result.isValid()); + assertEquals(MISSING_SOURCE_MESSAGE + " " + SOURCE_SPIFFE_HEADER + ": null", result.message()); + } + + @Test + public void testNonDelegationTokenWithUnparseableSourceHeaderIsRejected() throws Exception { + final K8sDestinationAudienceValidator validator = init(sourceHeaderParams()); + final Map headers = destinationHeaderOnly(); + headers.put(SOURCE_SPIFFE_HEADER, "not-a-spiffe-id"); + final HttpServletRequest request = requestWithHeaders(headers); + final AudienceValidationResult result = validator.validate(request, nonDelegationToken(MATCHING_AUD), null); + assertFalse(result.isValid()); + assertEquals(MISSING_SOURCE_MESSAGE + " " + SOURCE_SPIFFE_HEADER + ": not-a-spiffe-id", result.message()); + } +} From 909a2dfb1235859ed1c2189d67a85507ca2b37ee Mon Sep 17 00:00:00 2001 From: Harrison Date: Wed, 28 Oct 2026 09:42:48 -0700 Subject: [PATCH 2/5] KNOX-3534 - Add an ingress-source exception to the k8s destination audience validator A single topology can now authenticate both tokens arriving at the API through an ingress gateway and delegation tokens used for service-to-service calls. Four new parameters, all defaulting to the previous behavior: request.audience.k8s.ingress.source.spiffe-id.exception.enabled request.audience.k8s.ingress.source.spiffe-ids request.audience.k8s.ingress.source.spiffe-ids.skip.trust.domain.match request.audience.k8s.ingress.source.spiffe-ids.skip.validation When the exception is enabled and a request's source SPIFFE id names one of the configured ingress identities, a token with no act claim is either accepted without any audience check or validated against the fixed configured allowed-audience list, rather than against the request destination. A token that does carry an act claim is never exempted; such a token arriving from an ingress identity is rejected, since it authorizes a service-to-service call and does not legitimately arrive through the ingress. Enabling the exception without the source SPIFFE id header and the ingress identity list fails topology startup. With the exception left disabled, every token is validated exactly as before. Comments: Issues with test naming and structure Naming is confusing, switch from 'exception'. --- .../K8sDestinationAudienceValidator.java | 257 +++++++- .../filter/K8sIngressSourceExceptionTest.java | 566 ++++++++++++++++++ 2 files changed, 805 insertions(+), 18 deletions(-) create mode 100644 gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sIngressSourceExceptionTest.java diff --git a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sDestinationAudienceValidator.java b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sDestinationAudienceValidator.java index b12513482..6f952ac3c 100644 --- a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sDestinationAudienceValidator.java +++ b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sDestinationAudienceValidator.java @@ -49,14 +49,21 @@ * {@link AudienceResource#parseCustomForm(String, String)}. *

The first form is tried at all only when {@link #DNS_FORMAT_ENABLED_PARAM} is {@code true}, * its default; when it is {@code false}, every entry is evaluated only as the custom form. - *

A token carrying a delegation {@code act} claim is always routed through this validator; see - * {@link #validate(HttpServletRequest, JWT, List)}. A token with no {@code act} claim is too, - * unless {@link #VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM} is {@code false}. + *

A token carrying a delegation {@code act} claim is never exempted from validation; see + * {@link #validate(HttpServletRequest, JWT, List)}. It is destination-validated, except that + * when {@link #INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM} is {@code true} and the request's + * source SPIFFE id names one of the {@link #INGRESS_SOURCE_SPIFFE_IDS_PARAM}, such a token is + * rejected outright, because a delegation token must not arrive through the ingress. + * A token with no {@code act} claim is destination-validated too, unless the ingress-source + * exception applies to it or {@link #VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM} is {@code false}. * *

Which segments are actually compared depends entirely on which of this validator's header * parameters are configured; for an entry in the second, custom form, the cluster-domain segment * is the only one always enforced. Segments with no configured source are not compared at all, so * an {@code aud} entry that differs from the request only in an unconfigured segment is accepted. + * Those comparisons, and the rule that every configured header must be present and parseable, + * apply to a request that reaches destination validation, which a token exempted by the + * ingress-source exception does not. * *

One case departs from the rule that a segment with no configured source is not * compared. An {@code aud} entry written as a k8s service DNS name whose host carries no @@ -251,9 +258,11 @@ public class K8sDestinationAudienceValidator implements RequestAudienceValidator * {@link #ENFORCE_ACT_SUB_MATCHES_SOURCE_SPIFFE_ID_PARAM} is enabled. * *

When this parameter names a header, that header must be present and parseable as a - * SPIFFE id on every request, or audience validation fails, whether or not the value turns - * out to be needed for the {@code aud} entries actually presented. Every other header this - * validator is configured to read behaves the same way. + * SPIFFE id on every request that reaches destination validation, or audience validation + * fails, whether or not the value turns out to be needed for the {@code aud} entries actually + * presented. Every other header this validator is configured to read behaves the same way. A + * token exempted by {@link #INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM} does not reach + * destination validation, so that requirement does not apply to it. * *

The value of this header is trusted at face value, with no independent verification by * this filter. It must be populated only by a component trusted to set or overwrite it @@ -281,6 +290,10 @@ public class K8sDestinationAudienceValidator implements RequestAudienceValidator * *

When {@code false}, the {@code aud} claim of a token with no {@code act} claim is * validated against the fixed configured allowed-audience list. + * + *

Independent of this parameter, a token with no {@code act} claim whose source SPIFFE id + * names a configured ingress source is routed as described at + * {@link #INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM}. */ public static final String VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM = PARAM_PREFIX + "validate.audiences.without.act.claim"; @@ -331,6 +344,112 @@ public class K8sDestinationAudienceValidator implements RequestAudienceValidator public static final String ENFORCE_ACT_SUB_IS_SERVICE_ACCOUNT_PARAM = PARAM_PREFIX + "enforce.act.sub.is.service.account"; + /** + * Whether a token with no {@code act} claim, presented by a source workload whose SPIFFE id is + * listed in {@link #INGRESS_SOURCE_SPIFFE_IDS_PARAM}, is exempted from destination audience + * validation. Defaults to {@code false}, the fail-closed setting: with no exemption, every token + * is destination-validated. + * + *

This exemption never applies to a delegation token. A token carrying an {@code act} claim + * is not exempted by this parameter under any circumstances. The effect on such a token is the + * opposite one: when this parameter is {@code true} and the request's source SPIFFE id names a + * configured ingress source, a token with an {@code act} claim is rejected, because an + * {@code act} claim means the token must not arrive through the ingress. + * + *

What the exemption is depends on {@link #INGRESS_SOURCE_SPIFFE_IDS_SKIP_VALIDATION_PARAM}: + * either no audience validation at all, or validation against the fixed configured + * allowed-audience list instead of against the request destination. Either way the token does + * not reach destination validation, so the requirement that every configured header be present + * and parseable does not apply to it. + * + *

This parameter reads {@link #SOURCE_SPIFFE_ID_HEADER_PARAM} and + * {@link #INGRESS_SOURCE_SPIFFE_IDS_PARAM}, neither of which has a default. Setting this to + * {@code true} without both of them is a configuration error: initialization fails, rather than + * leaving an exemption a deployment asked for silently inactive. + * + *

The exemption is driven by the value of the header named by + * {@link #SOURCE_SPIFFE_ID_HEADER_PARAM}, which is trusted at face value. A caller able to set + * that header can obtain the exemption, so it must be set or overwritten by a trusted component + * before the request reaches Knox. This matters most with + * {@link #INGRESS_SOURCE_SPIFFE_IDS_SKIP_VALIDATION_PARAM}, which disables audience checking + * for the requests it exempts. + * + *

A request whose configured source SPIFFE id header is missing or unparseable does not name + * an ingress source, so it is not exempted; it continues to the ordinary flow. There it is + * rejected by the existing requirement that a configured header be present and parseable, + * unless {@link #VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM} is {@code false}, in which case a + * token with no {@code act} claim is validated against the fixed configured allowed-audience + * list. + */ + public static final String INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM = + PARAM_PREFIX + "ingress.source.spiffe-id.exception.enabled"; + + /** + * Comma-separated list of the source workload SPIFFE ids that identify a request as having + * arrived through an ingress gateway, each a full SPIFFE id, for example + * {@code spiffe://trust-domain/ns/namespace/sa/service-account}. No default. Only consulted + * when {@link #INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM} is {@code true}, which + * requires this to be set. + * + *

Each entry is compared against the value of the header named by + * {@link #SOURCE_SPIFFE_ID_HEADER_PARAM}. A request matches if any one entry matches. + * + *

Namespace and service account are compared exactly, ignoring case. No wildcard or partial + * form is supported in any portion. The trust domain is compared too, unless + * {@link #INGRESS_SOURCE_SPIFFE_IDS_SKIP_TRUST_DOMAIN_MATCH_PARAM} is {@code true}. + * + *

Every entry must be a full, parseable SPIFFE id including a trust domain, even when + * trust-domain matching is switched off, in which case the trust domain is parsed and then + * ignored. An entry that does not parse, including an empty entry left by a stray, leading or + * trailing comma, fails initialization, the same as any other malformed operator-supplied + * value. Entries are validated whether or not + * {@link #INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM} is {@code true}. + */ + public static final String INGRESS_SOURCE_SPIFFE_IDS_PARAM = + PARAM_PREFIX + "ingress.source.spiffe-ids"; + + /** + * Whether the trust-domain portion of an entry in {@link #INGRESS_SOURCE_SPIFFE_IDS_PARAM} is + * ignored when matching the request's source SPIFFE id against it, leaving only the namespace + * and the service account compared. Defaults to {@code false}, the fail-closed setting: all + * three portions must match. + * + *

This relaxes security and exists for a deployment that must trust an ingress identity + * whose trust domain is not known in advance. Setting it to {@code true} means any trust domain + * is accepted for that namespace and service-account pair. If the same namespace and + * service-account pair is used in more than one trust domain, those identities become + * indistinguishable to this check, and an ingress identity in one trust domain is accepted as + * the ingress identity of another. + * + *

Only consulted when {@link #INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM} is + * {@code true}. Setting this to {@code true} while that parameter is {@code false} has no + * effect. + */ + public static final String INGRESS_SOURCE_SPIFFE_IDS_SKIP_TRUST_DOMAIN_MATCH_PARAM = + PARAM_PREFIX + "ingress.source.spiffe-ids.skip.trust.domain.match"; + + /** + * Whether audience validation is skipped entirely for the tokens the ingress-source exception + * applies to, meaning a token with no {@code act} claim whose source SPIFFE id names a + * configured ingress source. Defaults to {@code false}. + * + *

When {@code true}, no audience check of any kind is performed on such a token: not against + * the request destination, and not against the fixed configured allowed-audience list. A token + * with no {@code aud} claim at all is accepted, and so is one whose {@code aud} names something + * unrelated to this request. + * + *

When {@code false}, the default, such a token's {@code aud} claim is validated against the + * fixed configured allowed-audience list instead of against the request destination. Note that + * the fixed-list check accepts any audience when no list is configured. + * + *

This affects no other token. A token with an {@code act} claim is unaffected by it, so is a + * token whose source SPIFFE id does not name a configured ingress source, and so is every token + * when {@link #INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM} is {@code false}. Setting this + * to {@code true} while that parameter is {@code false} has no effect. + */ + public static final String INGRESS_SOURCE_SPIFFE_IDS_SKIP_VALIDATION_PARAM = + PARAM_PREFIX + "ingress.source.spiffe-ids.skip.validation"; + private String namespaceFromSpiffeIdHeader; private String serverNameHeader; private String serverNameClusterSuffix; @@ -345,6 +464,10 @@ public class K8sDestinationAudienceValidator implements RequestAudienceValidator private boolean validateAudiencesWithoutActClaim; private boolean enforceActSubMatchesSourceSpiffeId; private boolean enforceActSubIsServiceAccount; + private boolean ingressSourceSpiffeIdExceptionEnabled; + private List ingressSourceSpiffeIds; + private boolean ingressSourceSpiffeIdsSkipTrustDomainMatch; + private boolean ingressSourceSpiffeIdsSkipValidation; @Override public void init(final FilterConfig filterConfig) throws Exception { @@ -399,19 +522,84 @@ public void init(final FilterConfig filterConfig) throws Exception { enforceActSubIsServiceAccount = Boolean.parseBoolean(filterConfig.getInitParameter(ENFORCE_ACT_SUB_IS_SERVICE_ACCOUNT_PARAM)); + ingressSourceSpiffeIdExceptionEnabled = Boolean.parseBoolean( + filterConfig.getInitParameter(INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM)); + ingressSourceSpiffeIdsSkipTrustDomainMatch = Boolean.parseBoolean( + filterConfig.getInitParameter(INGRESS_SOURCE_SPIFFE_IDS_SKIP_TRUST_DOMAIN_MATCH_PARAM)); + ingressSourceSpiffeIdsSkipValidation = Boolean.parseBoolean( + filterConfig.getInitParameter(INGRESS_SOURCE_SPIFFE_IDS_SKIP_VALIDATION_PARAM)); + + // Parsed strictly whether or not the exception is enabled, so a malformed entry fails the + // topology at startup instead of sitting inert. + final String ingressSourceSpiffeIdsParam = + blankToNull(filterConfig.getInitParameter(INGRESS_SOURCE_SPIFFE_IDS_PARAM)); + ingressSourceSpiffeIds = new ArrayList<>(); + if (ingressSourceSpiffeIdsParam != null) { + for (final String entry : parseCommaSeparated(ingressSourceSpiffeIdsParam)) { + final Optional parsed = SpiffeId.parse(entry); + if (parsed.isEmpty()) { + throw new ServletException(String.format(Locale.ROOT, + "%s contains an invalid SPIFFE id entry: %s", INGRESS_SOURCE_SPIFFE_IDS_PARAM, entry)); + } + ingressSourceSpiffeIds.add(lowerCased(parsed.get())); + } + } + if (enforceActSubMatchesSourceSpiffeId && sourceSpiffeIdHeader == null) { throw new ServletException(String.format(Locale.ROOT, "%s is true but %s is not configured", ENFORCE_ACT_SUB_MATCHES_SOURCE_SPIFFE_ID_PARAM, SOURCE_SPIFFE_ID_HEADER_PARAM)); } + + if (ingressSourceSpiffeIdExceptionEnabled) { + if (sourceSpiffeIdHeader == null) { + throw new ServletException(String.format(Locale.ROOT, + "%s is true but %s is not configured", INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM, + SOURCE_SPIFFE_ID_HEADER_PARAM)); + } + if (ingressSourceSpiffeIds.isEmpty()) { + throw new ServletException(String.format(Locale.ROOT, + "%s is true but %s is not configured", INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM, + INGRESS_SOURCE_SPIFFE_IDS_PARAM)); + } + } } @Override public AudienceValidationResult validate(final HttpServletRequest request, final JWT token, final List configuredAudiences) { final boolean hasActClaim = token.getClaimAsObject(JWTToken.ACT_CLAIM) != null; - if (!hasActClaim && !validateAudiencesWithoutActClaim) { - return AudienceValidationResult.of(AbstractJWTFilter.matchesConfiguredAudiences(token, configuredAudiences)); + + // Resolved before the early returns below because the ingress-source decision needs it. An + // absent value is not rejected here: that rejection happens further down, after the destination + // header and server-name checks, so a request whose source header is missing or unparseable + // is reported only once those earlier checks have passed. + final Optional sourceSpiffeId = sourceSpiffeIdHeader == null + ? Optional.empty() : sourceSpiffeIdFromHeader(request); + final boolean fromIngressSource = ingressSourceSpiffeIdExceptionEnabled + && isConfiguredIngressSource(sourceSpiffeId); + + if (!hasActClaim) { + if (fromIngressSource) { + // A token with no act claim arriving through the ingress names no in-cluster destination, + // so its audience is either not checked at all or checked against the fixed configured + // list. Both decisions are made before the aud claim is required to be present, because + // the fixed-list check has its own handling for a token that carries no aud claim. + return ingressSourceSpiffeIdsSkipValidation + ? AudienceValidationResult.of(true) + : AudienceValidationResult.of( + AbstractJWTFilter.matchesConfiguredAudiences(token, configuredAudiences)); + } + if (!validateAudiencesWithoutActClaim) { + return AudienceValidationResult.of( + AbstractJWTFilter.matchesConfiguredAudiences(token, configuredAudiences)); + } + } else if (fromIngressSource) { + // An act claim means the token authorizes one workload to call another inside the cluster. + // Arriving from an ingress identity means it was presented from outside, which no such call + // does. + return new AudienceValidationResult(false, "Token has an act claim but its source SPIFFE id in header " + + sourceSpiffeIdHeader + " is a configured ingress source: " + request.getHeader(sourceSpiffeIdHeader)); } final String[] audienceClaims = token.getAudienceClaims(); @@ -436,8 +624,6 @@ public AudienceValidationResult validate(final HttpServletRequest request, final + serverNameHeader + ": " + request.getHeader(serverNameHeader)); } - final Optional sourceSpiffeId = sourceSpiffeIdHeader == null - ? Optional.empty() : sourceSpiffeIdFromHeader(request); if (sourceSpiffeIdHeader != null && sourceSpiffeId.isEmpty()) { return new AudienceValidationResult(false, "Missing or unparseable source SPIFFE id in header " + sourceSpiffeIdHeader + ": " + request.getHeader(sourceSpiffeIdHeader)); @@ -658,20 +844,19 @@ private Optional destinationServiceNameFromHeader(final } /** - * All three fields are lower-cased here, once, on read -- the same convention - * {@link #namespaceFromHeader(HttpServletRequest)} and {@link DestinationServiceName} follow -- - * so every comparison against this value elsewhere in this class is a plain, case-sensitive - * {@code equals} rather than each comparison site remembering to lower-case its own copy. - * {@code trustDomain} is lowered too, for consistency, even though this validator never - * compares it. + * All three fields are lower-cased here, once, on read, by {@link #lowerCased(SpiffeId)} -- the + * same convention {@link #namespaceFromHeader(HttpServletRequest)} and + * {@link DestinationServiceName} follow -- so every comparison against this value elsewhere in + * this class is a plain, case-sensitive {@code equals} rather than each comparison site + * remembering to lower-case its own copy. {@code trustDomain} is lowered too because + * {@link #isConfiguredIngressSource(Optional)} compares it against the configured ingress ids. */ private Optional sourceSpiffeIdFromHeader(final HttpServletRequest request) { final String headerValue = request.getHeader(sourceSpiffeIdHeader); if (headerValue == null || headerValue.isEmpty()) { return Optional.empty(); } - return SpiffeId.parse(headerValue).map(id -> new SpiffeId(id.trustDomain().toLowerCase(Locale.ROOT), - id.namespace().toLowerCase(Locale.ROOT), id.serviceAccount().toLowerCase(Locale.ROOT))); + return SpiffeId.parse(headerValue).map(K8sDestinationAudienceValidator::lowerCased); } private Optional pathFromHeader(final HttpServletRequest request) { @@ -724,6 +909,31 @@ private Optional actSubMatchFailureReason(final Map most + " does not match source SPIFFE id service account " + sourceNamespace + ":" + sourceServiceAccount); } + /** + * Whether {@code sourceSpiffeId} names one of the configured ingress gateway identities. The + * namespace and service account must match an entry exactly; the trust domain must match too + * unless {@link #INGRESS_SOURCE_SPIFFE_IDS_SKIP_TRUST_DOMAIN_MATCH_PARAM} is enabled. An absent + * value, which is what a missing or unparseable source header produces, names no ingress + * identity and is reported as no match here rather than as a failure, so such a request + * continues to the ordinary flow and is rejected there by the existing requirement that a + * configured header be present and parseable. + */ + private boolean isConfiguredIngressSource(final Optional sourceSpiffeId) { + if (sourceSpiffeId.isEmpty()) { + return false; + } + final SpiffeId source = sourceSpiffeId.get(); + for (final SpiffeId allowed : ingressSourceSpiffeIds) { + if (allowed.namespace().equals(source.namespace()) + && allowed.serviceAccount().equals(source.serviceAccount()) + && (ingressSourceSpiffeIdsSkipTrustDomainMatch + || allowed.trustDomain().equals(source.trustDomain()))) { + return true; + } + } + return false; + } + @Override public String getName() { return VALIDATION_METHOD_VALUE; @@ -760,6 +970,17 @@ static Optional parse(final String entry) { } } + /** + * A copy of {@code id} with all three components lower-cased, so every comparison against a + * value read from a request is a plain, case-sensitive {@code equals}. The same convention + * {@link #sourceSpiffeIdFromHeader(HttpServletRequest)} applies on read. + */ + private static SpiffeId lowerCased(final SpiffeId id) { + return new SpiffeId(id.trustDomain().toLowerCase(Locale.ROOT), + id.namespace().toLowerCase(Locale.ROOT), + id.serviceAccount().toLowerCase(Locale.ROOT)); + } + private static String blankToNull(final String value) { return (value == null || value.trim().isEmpty()) ? null : value.trim(); } diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sIngressSourceExceptionTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sIngressSourceExceptionTest.java new file mode 100644 index 000000000..112c4aa1a --- /dev/null +++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sIngressSourceExceptionTest.java @@ -0,0 +1,566 @@ +/* + * 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.provider.federation.jwt.filter; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; + +import org.junit.Test; + +import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.actorChain; +import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.delegationToken; +import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.delegationTokenWithActClaim; +import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.filterConfig; +import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.nonDelegationToken; +import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.requestWithHeaders; + +/** + * Covers the ingress-source exception: the configuration it requires at initialization, which + * tokens it applies to, the two forms the exemption can take (no audience validation at all, or + * validation against the fixed configured allowed-audience list), how an allowed ingress identity + * is matched against the request's source SPIFFE id, and the rejection of a token carrying an + * {@code act} claim that arrives from an ingress source. + */ +public class K8sIngressSourceExceptionTest { + + private static final String SPIFFE_HEADER = "x-dest-spiffe-id"; + private static final String SOURCE_SPIFFE_HEADER = "x-source-spiffe-id"; + private static final String PATH_HEADER = "x-dest-path"; + private static final String SERVER_NAME_HEADER = "x-dest-server-name"; + private static final String CLUSTER_DOMAIN = "cluster.local"; + private static final String CLUSTER_SUFFIX = ".svc.cluster.local"; + private static final String DEST_SPIFFE_VALUE = "spiffe://trust-domain/ns/ns/sa/dest-sa"; + private static final String INGRESS_SPIFFE_ID = "spiffe://trust-domain/ns/istio-system/sa/ingressgateway"; + private static final String SECOND_INGRESS_SPIFFE_ID = "spiffe://trust-domain/ns/edge/sa/edge-gateway"; + private static final String INGRESS_SPIFFE_OTHER_TRUST_DOMAIN = + "spiffe://other-domain/ns/istio-system/sa/ingressgateway"; + private static final String NON_INGRESS_SPIFFE_ID = "spiffe://trust-domain/ns/ns/sa/caller-sa"; + private static final String MATCHING_AUD = "https://svc.ns"; + private static final String OTHER_NAMESPACE_AUD = "https://svc.other-ns"; + private static final String UNRELATED_AUD = "https://somewhere.else.example/nothing/to/do/with/this"; + private static final String WHITELIST_AUD = "configured-audience"; + private static final String MISSING_SOURCE_MESSAGE = "Missing or unparseable source SPIFFE id in header"; + + private static K8sDestinationAudienceValidator init(final Map params) throws Exception { + final K8sDestinationAudienceValidator validator = new K8sDestinationAudienceValidator(); + validator.init(filterConfig(params)); + return validator; + } + + private static Map baseParams() { + final Map params = new HashMap<>(); + params.put(K8sDestinationAudienceValidator.NAMESPACE_FROM_SPIFFE_ID_HEADER_PARAM, SPIFFE_HEADER); + params.put(K8sDestinationAudienceValidator.SOURCE_SPIFFE_ID_HEADER_PARAM, SOURCE_SPIFFE_HEADER); + params.put(K8sDestinationAudienceValidator.CLUSTER_DOMAINS_PARAM, CLUSTER_DOMAIN); + return params; + } + + private static Map exceptionParams() { + final Map params = baseParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM, "true"); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID); + return params; + } + + private static Map skipValidationParams() { + final Map params = exceptionParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_SKIP_VALIDATION_PARAM, "true"); + return params; + } + + private static Map headersFrom(final String sourceSpiffeId) { + final Map headers = new HashMap<>(); + headers.put(SPIFFE_HEADER, DEST_SPIFFE_VALUE); + if (sourceSpiffeId != null) { + headers.put(SOURCE_SPIFFE_HEADER, sourceSpiffeId); + } + return headers; + } + + private static HttpServletRequest requestFrom(final String sourceSpiffeId) { + return requestWithHeaders(headersFrom(sourceSpiffeId)); + } + + // ---- init() requirements ---- + + @Test + public void testInitThrowsWhenExceptionEnabledButSourceHeaderUnconfigured() { + final Map params = new HashMap<>(); + params.put(K8sDestinationAudienceValidator.NAMESPACE_FROM_SPIFFE_ID_HEADER_PARAM, SPIFFE_HEADER); + params.put(K8sDestinationAudienceValidator.CLUSTER_DOMAINS_PARAM, CLUSTER_DOMAIN); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM, "true"); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID); + final ServletException e = assertThrows(ServletException.class, () -> init(params)); + assertTrue(e.getMessage().contains( + K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM)); + assertTrue(e.getMessage().contains(K8sDestinationAudienceValidator.SOURCE_SPIFFE_ID_HEADER_PARAM)); + } + + @Test + public void testInitThrowsWhenExceptionEnabledButIngressIdListUnconfigured() { + final Map params = baseParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM, "true"); + final ServletException e = assertThrows(ServletException.class, () -> init(params)); + assertTrue(e.getMessage().contains( + K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM)); + assertTrue(e.getMessage().contains(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM)); + } + + @Test + public void testInitThrowsWhenExceptionEnabledAndIngressIdListIsBlank() { + final Map params = baseParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM, "true"); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, " "); + final ServletException e = assertThrows(ServletException.class, () -> init(params)); + assertTrue(e.getMessage().contains(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM)); + } + + @Test + public void testInitSucceedsWhenExceptionEnabledWithSourceHeaderAndIngressIdList() throws Exception { + init(exceptionParams()); + } + + @Test + public void testInitThrowsOnUnparseableIngressSpiffeIdEntry() { + final Map params = exceptionParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, "not-a-spiffe-id"); + final ServletException e = assertThrows(ServletException.class, () -> init(params)); + assertTrue(e.getMessage().contains(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM)); + assertTrue(e.getMessage().contains("not-a-spiffe-id")); + } + + @Test + public void testInitThrowsOnIngressSpiffeIdEntryWithoutNamespaceAndServiceAccountPath() { + final Map params = exceptionParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, "spiffe://trust-domain/ns/only"); + assertThrows(ServletException.class, () -> init(params)); + } + + @Test + public void testInitThrowsOnIngressSpiffeIdsWithStrayInteriorComma() { + final Map params = exceptionParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, + INGRESS_SPIFFE_ID + ",," + SECOND_INGRESS_SPIFFE_ID); + assertThrows(ServletException.class, () -> init(params)); + } + + @Test + public void testInitThrowsOnIngressSpiffeIdsWithTrailingComma() { + final Map params = exceptionParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID + ","); + assertThrows(ServletException.class, () -> init(params)); + } + + @Test + public void testInitThrowsOnIngressSpiffeIdsWithLeadingComma() { + final Map params = exceptionParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, "," + INGRESS_SPIFFE_ID); + assertThrows(ServletException.class, () -> init(params)); + } + + @Test + public void testInitSucceedsWithTwoIngressSpiffeIdEntries() throws Exception { + final Map params = exceptionParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, + " " + INGRESS_SPIFFE_ID + " , " + SECOND_INGRESS_SPIFFE_ID + " "); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_SKIP_VALIDATION_PARAM, "true"); + final K8sDestinationAudienceValidator validator = init(params); + // Both entries were trimmed on the way in: a source naming either one is exempted. + assertTrue(validator.validate(requestFrom(INGRESS_SPIFFE_ID), nonDelegationToken(OTHER_NAMESPACE_AUD), null) + .isValid()); + assertTrue(validator.validate(requestFrom(SECOND_INGRESS_SPIFFE_ID), + nonDelegationToken(OTHER_NAMESPACE_AUD), null).isValid()); + } + + @Test + public void testInitSucceedsWithIngressIdListConfiguredWhileExceptionDisabled() throws Exception { + final Map params = baseParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID); + init(params); + } + + @Test + public void testInitSucceedsWithSkipFlagsSetWhileExceptionDisabled() throws Exception { + final Map params = baseParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_SKIP_VALIDATION_PARAM, "true"); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_SKIP_TRUST_DOMAIN_MATCH_PARAM, "true"); + init(params); + } + + @Test + public void testInitThrowsOnUnparseableIngressSpiffeIdEntryEvenWhenExceptionDisabled() { + final Map params = baseParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, "not-a-spiffe-id"); + assertThrows(ServletException.class, () -> init(params)); + } + + // ---- exception on, skip.validation at its default of false ---- + + @Test + public void testIngressSourceWithoutActClaimIsValidatedAgainstConfiguredAudiences() throws Exception { + final K8sDestinationAudienceValidator validator = init(exceptionParams()); + assertTrue(validator.validate(requestFrom(INGRESS_SPIFFE_ID), nonDelegationToken(WHITELIST_AUD), + Collections.singletonList(WHITELIST_AUD)).isValid()); + } + + @Test + public void testIngressSourceWithoutActClaimRejectedWhenAudienceNotInConfiguredList() throws Exception { + final K8sDestinationAudienceValidator validator = init(exceptionParams()); + assertFalse(validator.validate(requestFrom(INGRESS_SPIFFE_ID), nonDelegationToken(WHITELIST_AUD), + Collections.singletonList("some-other-audience")).isValid()); + } + + @Test + public void testIngressSourceWithoutActClaimIsNotDestinationValidated() throws Exception { + final K8sDestinationAudienceValidator validator = init(exceptionParams()); + // MATCHING_AUD would pass destination validation but is not in the configured list: the list + // replaces destination matching rather than being tried in addition to it. + assertFalse(validator.validate(requestFrom(INGRESS_SPIFFE_ID), nonDelegationToken(MATCHING_AUD), + Collections.singletonList(WHITELIST_AUD)).isValid()); + } + + @Test + public void testIngressSourceWithoutActClaimAcceptedWhenConfiguredAudiencesNull() throws Exception { + final K8sDestinationAudienceValidator validator = init(exceptionParams()); + // The fixed-list check treats a null configured list as "any audience is acceptable". + assertTrue(validator.validate(requestFrom(INGRESS_SPIFFE_ID), nonDelegationToken(WHITELIST_AUD), null) + .isValid()); + } + + @Test + public void testIngressSourceWithoutActClaimAndNoAudClaimReachesConfiguredAudiences() throws Exception { + final K8sDestinationAudienceValidator validator = init(exceptionParams()); + // Reaching the "NONE" handling in the fixed-list check proves the ingress route runs before this + // validator's own rejection of a token with no aud claim. + assertTrue(validator.validate(requestFrom(INGRESS_SPIFFE_ID), nonDelegationToken((String[]) null), + Collections.singletonList("NONE")).isValid()); + } + + @Test + public void testIngressSourceWithoutActClaimAndNoAudClaimRejectedWithoutNoneSentinel() throws Exception { + final K8sDestinationAudienceValidator validator = init(exceptionParams()); + final AudienceValidationResult result = validator.validate(requestFrom(INGRESS_SPIFFE_ID), + nonDelegationToken((String[]) null), Collections.singletonList(WHITELIST_AUD)); + assertFalse(result.isValid()); + // The fixed-list check carries no message, which also shows this validator's own aud-presence + // rejection did not run. + assertNull(result.message()); + } + + // ---- exception on, skip.validation true ---- + + @Test + public void testSkipValidationAcceptsIngressSourceWithUnrelatedAudience() throws Exception { + final K8sDestinationAudienceValidator validator = init(skipValidationParams()); + assertTrue(validator.validate(requestFrom(INGRESS_SPIFFE_ID), nonDelegationToken(UNRELATED_AUD), + Collections.singletonList(WHITELIST_AUD)).isValid()); + } + + @Test + public void testSkipValidationAcceptsIngressSourceWithNoAudClaimAtAll() throws Exception { + final K8sDestinationAudienceValidator validator = init(skipValidationParams()); + assertTrue(validator.validate(requestFrom(INGRESS_SPIFFE_ID), nonDelegationToken((String[]) null), null) + .isValid()); + assertTrue(validator.validate(requestFrom(INGRESS_SPIFFE_ID), nonDelegationToken((String[]) null), + Collections.singletonList(WHITELIST_AUD)).isValid()); + } + + @Test + public void testSkipValidationResultCarriesNoMessage() throws Exception { + final K8sDestinationAudienceValidator validator = init(skipValidationParams()); + final AudienceValidationResult result = validator.validate(requestFrom(INGRESS_SPIFFE_ID), + nonDelegationToken(UNRELATED_AUD), Collections.singletonList(WHITELIST_AUD)); + assertTrue(result.isValid()); + assertNull(result.message()); + } + + @Test + public void testSkipValidationAcceptsIngressSourceWhenConfiguredPathHeaderIsAbsent() throws Exception { + final Map params = skipValidationParams(); + params.put(K8sDestinationAudienceValidator.PATH_HEADER_PARAM, PATH_HEADER); + final K8sDestinationAudienceValidator validator = init(params); + // The exemption precedes the rule that every configured header be present, so an exempted + // request is accepted even though the configured path header is missing from it. + assertTrue(validator.validate(requestFrom(INGRESS_SPIFFE_ID), nonDelegationToken((String[]) null), null) + .isValid()); + } + + @Test + public void testSkipValidationHasNoEffectWhenExceptionDisabled() throws Exception { + final Map params = baseParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_SKIP_VALIDATION_PARAM, "true"); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID); + final K8sDestinationAudienceValidator validator = init(params); + assertFalse(validator.validate(requestFrom(INGRESS_SPIFFE_ID), nonDelegationToken(OTHER_NAMESPACE_AUD), null) + .isValid()); + } + + // ---- exception on, source is not an ingress identity ---- + + @Test + public void testNonIngressSourceWithoutActClaimIsDestinationValidatedAndAccepted() throws Exception { + final K8sDestinationAudienceValidator validator = init(exceptionParams()); + assertTrue(validator.validate(requestFrom(NON_INGRESS_SPIFFE_ID), nonDelegationToken(MATCHING_AUD), null) + .isValid()); + } + + @Test + public void testNonIngressSourceWithoutActClaimIsDestinationValidatedAndRejected() throws Exception { + final K8sDestinationAudienceValidator validator = init(exceptionParams()); + final AudienceValidationResult result = + validator.validate(requestFrom(NON_INGRESS_SPIFFE_ID), nonDelegationToken(OTHER_NAMESPACE_AUD), null); + assertFalse(result.isValid()); + assertTrue(result.message().contains("does not match the destination")); + } + + @Test + public void testNonIngressSourceWithoutActClaimFallsBackToWhitelistWhenConfigured() throws Exception { + final Map params = exceptionParams(); + params.put(K8sDestinationAudienceValidator.VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM, "false"); + final K8sDestinationAudienceValidator validator = init(params); + assertTrue(validator.validate(requestFrom(NON_INGRESS_SPIFFE_ID), nonDelegationToken(WHITELIST_AUD), + Collections.singletonList(WHITELIST_AUD)).isValid()); + } + + @Test + public void testExceptionEnabledWithMissingSourceHeaderIsRejected() throws Exception { + final K8sDestinationAudienceValidator validator = init(exceptionParams()); + final AudienceValidationResult result = + validator.validate(requestFrom(null), nonDelegationToken(MATCHING_AUD), null); + assertFalse(result.isValid()); + assertTrue(result.message().contains(MISSING_SOURCE_MESSAGE)); + } + + @Test + public void testExceptionEnabledWithUnparseableSourceHeaderIsRejected() throws Exception { + final K8sDestinationAudienceValidator validator = init(exceptionParams()); + final AudienceValidationResult result = + validator.validate(requestFrom("not-a-spiffe-id"), nonDelegationToken(MATCHING_AUD), null); + assertFalse(result.isValid()); + assertTrue(result.message().contains(MISSING_SOURCE_MESSAGE)); + } + + @Test + public void testMissingSourceHeaderFallsBackToWhitelistWhenValidateWithoutActClaimFalse() throws Exception { + final Map params = exceptionParams(); + params.put(K8sDestinationAudienceValidator.VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM, "false"); + final K8sDestinationAudienceValidator validator = init(params); + assertTrue(validator.validate(requestFrom(null), nonDelegationToken(WHITELIST_AUD), + Collections.singletonList(WHITELIST_AUD)).isValid()); + } + + @Test + public void testSkipValidationAcceptsIngressSourceWhenValidateWithoutActClaimFalse() throws Exception { + final Map params = skipValidationParams(); + params.put(K8sDestinationAudienceValidator.VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM, "false"); + final K8sDestinationAudienceValidator validator = init(params); + assertTrue(validator.validate(requestFrom(INGRESS_SPIFFE_ID), nonDelegationToken(UNRELATED_AUD), + Collections.singletonList(WHITELIST_AUD)).isValid()); + } + + @Test + public void testSourceMatchingNoneOfSeveralConfiguredIngressIdsIsNotExempted() throws Exception { + final Map params = skipValidationParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, + INGRESS_SPIFFE_ID + "," + SECOND_INGRESS_SPIFFE_ID); + final K8sDestinationAudienceValidator validator = init(params); + assertFalse(validator.validate(requestFrom(NON_INGRESS_SPIFFE_ID), nonDelegationToken(OTHER_NAMESPACE_AUD), + null).isValid()); + } + + @Test + public void testSourceMatchingTheSecondConfiguredIngressIdIsExempted() throws Exception { + final Map params = skipValidationParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, + INGRESS_SPIFFE_ID + "," + SECOND_INGRESS_SPIFFE_ID); + final K8sDestinationAudienceValidator validator = init(params); + assertTrue(validator.validate(requestFrom(SECOND_INGRESS_SPIFFE_ID), nonDelegationToken(OTHER_NAMESPACE_AUD), + null).isValid()); + } + + // ---- trust-domain matching ---- + + @Test + public void testDifferentTrustDomainIsNotAnIngressSourceByDefault() throws Exception { + final K8sDestinationAudienceValidator validator = init(skipValidationParams()); + assertFalse(validator.validate(requestFrom(INGRESS_SPIFFE_OTHER_TRUST_DOMAIN), + nonDelegationToken(OTHER_NAMESPACE_AUD), null).isValid()); + } + + @Test + public void testDifferentTrustDomainIsAnIngressSourceWhenTrustDomainMatchSkipped() throws Exception { + final Map params = skipValidationParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_SKIP_TRUST_DOMAIN_MATCH_PARAM, "true"); + final K8sDestinationAudienceValidator validator = init(params); + assertTrue(validator.validate(requestFrom(INGRESS_SPIFFE_OTHER_TRUST_DOMAIN), + nonDelegationToken(OTHER_NAMESPACE_AUD), null).isValid()); + } + + @Test + public void testSkippingTrustDomainMatchStillRequiresNamespaceToMatch() throws Exception { + final Map params = skipValidationParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_SKIP_TRUST_DOMAIN_MATCH_PARAM, "true"); + final K8sDestinationAudienceValidator validator = init(params); + assertFalse(validator.validate(requestFrom("spiffe://other-domain/ns/other-ns/sa/ingressgateway"), + nonDelegationToken(OTHER_NAMESPACE_AUD), null).isValid()); + } + + @Test + public void testSkippingTrustDomainMatchStillRequiresServiceAccountToMatch() throws Exception { + final Map params = skipValidationParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_SKIP_TRUST_DOMAIN_MATCH_PARAM, "true"); + final K8sDestinationAudienceValidator validator = init(params); + assertFalse(validator.validate(requestFrom("spiffe://other-domain/ns/istio-system/sa/not-the-gateway"), + nonDelegationToken(OTHER_NAMESPACE_AUD), null).isValid()); + } + + @Test + public void testIngressSpiffeIdMatchingIsCaseInsensitive() throws Exception { + final Map mixedCaseConfigured = skipValidationParams(); + mixedCaseConfigured.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, + "spiffe://Trust-Domain/ns/Istio-System/sa/IngressGateway"); + assertTrue(init(mixedCaseConfigured).validate(requestFrom(INGRESS_SPIFFE_ID), + nonDelegationToken(OTHER_NAMESPACE_AUD), null).isValid()); + + assertTrue(init(skipValidationParams()).validate( + requestFrom("spiffe://Trust-Domain/ns/Istio-System/sa/IngressGateway"), + nonDelegationToken(OTHER_NAMESPACE_AUD), null).isValid()); + } + + @Test + public void testSkipTrustDomainMatchHasNoEffectWhenExceptionDisabled() throws Exception { + final Map params = baseParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_SKIP_TRUST_DOMAIN_MATCH_PARAM, "true"); + final K8sDestinationAudienceValidator validator = init(params); + assertFalse(validator.validate(requestFrom(INGRESS_SPIFFE_OTHER_TRUST_DOMAIN), + nonDelegationToken(OTHER_NAMESPACE_AUD), null).isValid()); + } + + // ---- a token with an act claim from an ingress source ---- + + @Test + public void testDelegationTokenFromIngressSourceIsRejected() throws Exception { + final K8sDestinationAudienceValidator validator = init(exceptionParams()); + final AudienceValidationResult result = + validator.validate(requestFrom(INGRESS_SPIFFE_ID), delegationToken(MATCHING_AUD), null); + assertFalse(result.isValid()); + assertTrue(result.message().contains("act claim")); + assertTrue(result.message().contains("ingress")); + assertTrue(result.message().contains(SOURCE_SPIFFE_HEADER)); + } + + @Test + public void testDelegationTokenFromIngressSourceIsRejectedEvenWhenSkipValidationTrue() throws Exception { + final K8sDestinationAudienceValidator validator = init(skipValidationParams()); + assertFalse(validator.validate(requestFrom(INGRESS_SPIFFE_ID), delegationToken(MATCHING_AUD), null) + .isValid()); + } + + @Test + public void testDelegationTokenFromIngressSourceIsRejectedWithNoAudClaim() throws Exception { + final K8sDestinationAudienceValidator validator = init(exceptionParams()); + final AudienceValidationResult result = + validator.validate(requestFrom(INGRESS_SPIFFE_ID), delegationToken((String[]) null), null); + assertFalse(result.isValid()); + assertTrue(result.message().contains("ingress")); + assertFalse(result.message().contains("Token has an act claim but no aud claim")); + } + + @Test + public void testDelegationTokenFromIngressSourceIsAcceptedWhenExceptionDisabled() throws Exception { + final Map params = baseParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID); + final K8sDestinationAudienceValidator validator = init(params); + assertTrue(validator.validate(requestFrom(INGRESS_SPIFFE_ID), delegationToken(MATCHING_AUD), null).isValid()); + } + + @Test + public void testDelegationTokenFromNonIngressSourceIsDestinationValidated() throws Exception { + final K8sDestinationAudienceValidator validator = init(exceptionParams()); + assertTrue(validator.validate(requestFrom(NON_INGRESS_SPIFFE_ID), delegationToken(MATCHING_AUD), null) + .isValid()); + } + + @Test + public void testMalformedActClaimFromIngressSourceIsRejected() throws Exception { + final K8sDestinationAudienceValidator validator = init(exceptionParams()); + final AudienceValidationResult result = validator.validate(requestFrom(INGRESS_SPIFFE_ID), + delegationTokenWithActClaim("not-a-map", MATCHING_AUD), null); + assertFalse(result.isValid()); + assertTrue(result.message().contains("ingress")); + } + + @Test + public void testDelegationTokenFromIngressSourceRejectedBeforeActSubBindingRuns() throws Exception { + final Map params = exceptionParams(); + params.put(K8sDestinationAudienceValidator.ENFORCE_ACT_SUB_MATCHES_SOURCE_SPIFFE_ID_PARAM, "true"); + final K8sDestinationAudienceValidator validator = init(params); + // The act sub names the ingress service account, so the binding check on its own would pass. + final AudienceValidationResult result = validator.validate(requestFrom(INGRESS_SPIFFE_ID), + delegationTokenWithActClaim(actorChain("system:serviceaccount:istio-system:ingressgateway"), + MATCHING_AUD), null); + assertFalse(result.isValid()); + assertTrue(result.message().contains("ingress")); + } + + // ---- the destination-validation checks that follow the routing ---- + + @Test + public void testRequireAllAudiencesMatchStillAppliesWhenExceptionEnabledButNotIngress() throws Exception { + final Map params = exceptionParams(); + params.put(K8sDestinationAudienceValidator.REQUIRE_ALL_AUDIENCES_MATCH_PARAM, "true"); + final K8sDestinationAudienceValidator validator = init(params); + assertFalse(validator.validate(requestFrom(NON_INGRESS_SPIFFE_ID), + delegationToken(MATCHING_AUD, OTHER_NAMESPACE_AUD), null).isValid()); + } + + @Test + public void testRequireAllAudiencesMatchDoesNotApplyToTheWhitelistRoute() throws Exception { + final Map params = exceptionParams(); + params.put(K8sDestinationAudienceValidator.REQUIRE_ALL_AUDIENCES_MATCH_PARAM, "true"); + final K8sDestinationAudienceValidator validator = init(params); + // The fixed-list check accepts a token when any one of its audiences is in the list. + assertTrue(validator.validate(requestFrom(INGRESS_SPIFFE_ID), + nonDelegationToken(WHITELIST_AUD, UNRELATED_AUD), Arrays.asList(WHITELIST_AUD, "another-audience")) + .isValid()); + } + + @Test + public void testBothNamespaceSourcesStillMustAgreeWhenExceptionEnabledButNotIngress() throws Exception { + final Map params = exceptionParams(); + params.put(K8sDestinationAudienceValidator.SERVER_NAME_HEADER_PARAM, SERVER_NAME_HEADER); + final K8sDestinationAudienceValidator validator = init(params); + final Map headers = headersFrom(NON_INGRESS_SPIFFE_ID); + headers.put(SERVER_NAME_HEADER, "svc.other-ns" + CLUSTER_SUFFIX); + final AudienceValidationResult result = + validator.validate(requestWithHeaders(headers), nonDelegationToken(MATCHING_AUD), null); + assertFalse(result.isValid()); + assertTrue(result.message().contains("disagrees with namespace")); + } +} From 4324e9d60dfa17fd87aa855047777e0149eeed7c Mon Sep 17 00:00:00 2001 From: Harrison Date: Wed, 28 Oct 2026 16:22:43 -0700 Subject: [PATCH 3/5] Change param names and related javadoc and class names. Refactor tests with better named helpers. --- .../K8sDestinationAudienceValidator.java | 154 ++--- .../filter/K8sIngressSourceExceptionTest.java | 566 ---------------- .../filter/K8sIngressSourceRoutingTest.java | 603 ++++++++++++++++++ .../MatchesConfiguredAudiencesTest.java | 27 + 4 files changed, 709 insertions(+), 641 deletions(-) delete mode 100644 gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sIngressSourceExceptionTest.java create mode 100644 gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sIngressSourceRoutingTest.java diff --git a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sDestinationAudienceValidator.java b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sDestinationAudienceValidator.java index 6f952ac3c..ba76233ba 100644 --- a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sDestinationAudienceValidator.java +++ b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sDestinationAudienceValidator.java @@ -49,21 +49,21 @@ * {@link AudienceResource#parseCustomForm(String, String)}. *

The first form is tried at all only when {@link #DNS_FORMAT_ENABLED_PARAM} is {@code true}, * its default; when it is {@code false}, every entry is evaluated only as the custom form. - *

A token carrying a delegation {@code act} claim is never exempted from validation; see - * {@link #validate(HttpServletRequest, JWT, List)}. It is destination-validated, except that - * when {@link #INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM} is {@code true} and the request's + *

A token carrying a delegation {@code act} claim is always destination-validated; see + * {@link #validate(HttpServletRequest, JWT, List)}. The one departure is ingress source routing: + * when {@link #INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM} is {@code true} and the request's * source SPIFFE id names one of the {@link #INGRESS_SOURCE_SPIFFE_IDS_PARAM}, such a token is * rejected outright, because a delegation token must not arrive through the ingress. - * A token with no {@code act} claim is destination-validated too, unless the ingress-source - * exception applies to it or {@link #VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM} is {@code false}. + * A token with no {@code act} claim is destination-validated too, unless ingress source routing + * applies to it or {@link #VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM} is {@code false}. * *

Which segments are actually compared depends entirely on which of this validator's header * parameters are configured; for an entry in the second, custom form, the cluster-domain segment * is the only one always enforced. Segments with no configured source are not compared at all, so * an {@code aud} entry that differs from the request only in an unconfigured segment is accepted. * Those comparisons, and the rule that every configured header must be present and parseable, - * apply to a request that reaches destination validation, which a token exempted by the - * ingress-source exception does not. + * apply to a request that reaches destination validation, which a token with no {@code act} claim + * routed by ingress source routing does not. * *

One case departs from the rule that a segment with no configured source is not * compared. An {@code aud} entry written as a k8s service DNS name whose host carries no @@ -261,8 +261,9 @@ public class K8sDestinationAudienceValidator implements RequestAudienceValidator * SPIFFE id on every request that reaches destination validation, or audience validation * fails, whether or not the value turns out to be needed for the {@code aud} entries actually * presented. Every other header this validator is configured to read behaves the same way. A - * token exempted by {@link #INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM} does not reach - * destination validation, so that requirement does not apply to it. + * token with no {@code act} claim routed by + * {@link #INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM} does not reach destination + * validation, so that requirement does not apply to it. * *

The value of this header is trusted at face value, with no independent verification by * this filter. It must be populated only by a component trusted to set or overwrite it @@ -293,7 +294,7 @@ public class K8sDestinationAudienceValidator implements RequestAudienceValidator * *

Independent of this parameter, a token with no {@code act} claim whose source SPIFFE id * names a configured ingress source is routed as described at - * {@link #INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM}. + * {@link #INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM}. */ public static final String VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM = PARAM_PREFIX + "validate.audiences.without.act.claim"; @@ -345,74 +346,77 @@ public class K8sDestinationAudienceValidator implements RequestAudienceValidator PARAM_PREFIX + "enforce.act.sub.is.service.account"; /** - * Whether a token with no {@code act} claim, presented by a source workload whose SPIFFE id is - * listed in {@link #INGRESS_SOURCE_SPIFFE_IDS_PARAM}, is exempted from destination audience - * validation. Defaults to {@code false}, the fail-closed setting: with no exemption, every token - * is destination-validated. - * - *

This exemption never applies to a delegation token. A token carrying an {@code act} claim - * is not exempted by this parameter under any circumstances. The effect on such a token is the - * opposite one: when this parameter is {@code true} and the request's source SPIFFE id names a - * configured ingress source, a token with an {@code act} claim is rejected, because an - * {@code act} claim means the token must not arrive through the ingress. - * - *

What the exemption is depends on {@link #INGRESS_SOURCE_SPIFFE_IDS_SKIP_VALIDATION_PARAM}: - * either no audience validation at all, or validation against the fixed configured - * allowed-audience list instead of against the request destination. Either way the token does - * not reach destination validation, so the requirement that every configured header be present - * and parseable does not apply to it. + * Whether a request presented by an ingress gateway is routed to a different audience check + * than validation against the request destination. A request is from an ingress gateway when + * the SPIFFE id in the header named by {@link #SOURCE_SPIFFE_ID_HEADER_PARAM} matches an entry + * of {@link #INGRESS_SOURCE_SPIFFE_IDS_PARAM}. Defaults to {@code false}, the fail-closed + * setting: with routing off, a request from an ingress gateway is handled like a request from + * any other source. + * + *

When {@code true}, a request from an ingress gateway is routed by whether its token + * carries an {@code act} claim. + * + *

A token with no {@code act} claim is not destination-validated. What it is checked + * against depends on {@link #INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_VALIDATION_PARAM}: either + * no audience validation at all, or the fixed configured allowed-audience list. Either way the + * token does not reach destination validation, so the requirement that every configured header + * be present and parseable does not apply to it. + * + *

A token with an {@code act} claim is rejected, because a delegation token must not + * arrive through the ingress. Routing never lets such a token through, whatever the skip setting. * *

This parameter reads {@link #SOURCE_SPIFFE_ID_HEADER_PARAM} and * {@link #INGRESS_SOURCE_SPIFFE_IDS_PARAM}, neither of which has a default. Setting this to * {@code true} without both of them is a configuration error: initialization fails, rather than - * leaving an exemption a deployment asked for silently inactive. + * leaving routing a deployment asked for silently inactive. * - *

The exemption is driven by the value of the header named by + *

Routing is driven by the value of the header named by * {@link #SOURCE_SPIFFE_ID_HEADER_PARAM}, which is trusted at face value. A caller able to set - * that header can obtain the exemption, so it must be set or overwritten by a trusted component - * before the request reaches Knox. This matters most with - * {@link #INGRESS_SOURCE_SPIFFE_IDS_SKIP_VALIDATION_PARAM}, which disables audience checking - * for the requests it exempts. + * that header can have its request routed as an ingress request, so it must be set or overwritten by a trusted + * component before the request reaches Knox. This matters most with + * {@link #INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_VALIDATION_PARAM}, which disables audience + * checking for the requests it routes. * *

A request whose configured source SPIFFE id header is missing or unparseable does not name - * an ingress source, so it is not exempted; it continues to the ordinary flow. There it is + * an ingress source, so it is not routed; it continues to the ordinary flow. There it is * rejected by the existing requirement that a configured header be present and parseable, * unless {@link #VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM} is {@code false}, in which case a * token with no {@code act} claim is validated against the fixed configured allowed-audience * list. */ - public static final String INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM = - PARAM_PREFIX + "ingress.source.spiffe-id.exception.enabled"; + public static final String INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM = + PARAM_PREFIX + "ingress.source.spiffe-id.routing.enabled"; /** * Comma-separated list of the source workload SPIFFE ids that identify a request as having * arrived through an ingress gateway, each a full SPIFFE id, for example - * {@code spiffe://trust-domain/ns/namespace/sa/service-account}. No default. Only consulted - * when {@link #INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM} is {@code true}, which - * requires this to be set. + * {@code spiffe://trust-domain/ns/namespace/sa/service-account}. No default. Currently consulted + * only by ingress source routing, so only when + * {@link #INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM} is {@code true}, which requires this + * to be set. * *

Each entry is compared against the value of the header named by * {@link #SOURCE_SPIFFE_ID_HEADER_PARAM}. A request matches if any one entry matches. * *

Namespace and service account are compared exactly, ignoring case. No wildcard or partial * form is supported in any portion. The trust domain is compared too, unless - * {@link #INGRESS_SOURCE_SPIFFE_IDS_SKIP_TRUST_DOMAIN_MATCH_PARAM} is {@code true}. + * {@link #INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_TRUST_DOMAIN_MATCH_PARAM} is {@code true}. * *

Every entry must be a full, parseable SPIFFE id including a trust domain, even when * trust-domain matching is switched off, in which case the trust domain is parsed and then * ignored. An entry that does not parse, including an empty entry left by a stray, leading or * trailing comma, fails initialization, the same as any other malformed operator-supplied * value. Entries are validated whether or not - * {@link #INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM} is {@code true}. + * {@link #INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM} is {@code true}. */ public static final String INGRESS_SOURCE_SPIFFE_IDS_PARAM = PARAM_PREFIX + "ingress.source.spiffe-ids"; /** - * Whether the trust-domain portion of an entry in {@link #INGRESS_SOURCE_SPIFFE_IDS_PARAM} is - * ignored when matching the request's source SPIFFE id against it, leaving only the namespace - * and the service account compared. Defaults to {@code false}, the fail-closed setting: all - * three portions must match. + * Whether ingress source routing ignores the trust-domain portion of an entry in + * {@link #INGRESS_SOURCE_SPIFFE_IDS_PARAM} when deciding whether the request's source SPIFFE id + * matches it, leaving only the namespace and the service account compared. Defaults to + * {@code false}, the fail-closed setting: all three portions must match. * *

This relaxes security and exists for a deployment that must trust an ingress identity * whose trust domain is not known in advance. Setting it to {@code true} means any trust domain @@ -421,17 +425,17 @@ public class K8sDestinationAudienceValidator implements RequestAudienceValidator * indistinguishable to this check, and an ingress identity in one trust domain is accepted as * the ingress identity of another. * - *

Only consulted when {@link #INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM} is + *

Only consulted when {@link #INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM} is * {@code true}. Setting this to {@code true} while that parameter is {@code false} has no * effect. */ - public static final String INGRESS_SOURCE_SPIFFE_IDS_SKIP_TRUST_DOMAIN_MATCH_PARAM = - PARAM_PREFIX + "ingress.source.spiffe-ids.skip.trust.domain.match"; + public static final String INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_TRUST_DOMAIN_MATCH_PARAM = + PARAM_PREFIX + "ingress.source.spiffe-ids.routing.skip.trust.domain.match"; /** - * Whether audience validation is skipped entirely for the tokens the ingress-source exception - * applies to, meaning a token with no {@code act} claim whose source SPIFFE id names a - * configured ingress source. Defaults to {@code false}. + * Whether ingress source routing skips audience validation entirely for a token with no + * {@code act} claim whose source SPIFFE id names a configured ingress source. Defaults to + * {@code false}. * *

When {@code true}, no audience check of any kind is performed on such a token: not against * the request destination, and not against the fixed configured allowed-audience list. A token @@ -444,11 +448,11 @@ public class K8sDestinationAudienceValidator implements RequestAudienceValidator * *

This affects no other token. A token with an {@code act} claim is unaffected by it, so is a * token whose source SPIFFE id does not name a configured ingress source, and so is every token - * when {@link #INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM} is {@code false}. Setting this + * when {@link #INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM} is {@code false}. Setting this * to {@code true} while that parameter is {@code false} has no effect. */ - public static final String INGRESS_SOURCE_SPIFFE_IDS_SKIP_VALIDATION_PARAM = - PARAM_PREFIX + "ingress.source.spiffe-ids.skip.validation"; + public static final String INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_VALIDATION_PARAM = + PARAM_PREFIX + "ingress.source.spiffe-ids.routing.skip.validation"; private String namespaceFromSpiffeIdHeader; private String serverNameHeader; @@ -464,10 +468,10 @@ public class K8sDestinationAudienceValidator implements RequestAudienceValidator private boolean validateAudiencesWithoutActClaim; private boolean enforceActSubMatchesSourceSpiffeId; private boolean enforceActSubIsServiceAccount; - private boolean ingressSourceSpiffeIdExceptionEnabled; + private boolean ingressSourceRoutingEnabled; private List ingressSourceSpiffeIds; - private boolean ingressSourceSpiffeIdsSkipTrustDomainMatch; - private boolean ingressSourceSpiffeIdsSkipValidation; + private boolean ingressSourceRoutingSkipTrustDomainMatch; + private boolean ingressSourceRoutingSkipValidation; @Override public void init(final FilterConfig filterConfig) throws Exception { @@ -522,14 +526,14 @@ public void init(final FilterConfig filterConfig) throws Exception { enforceActSubIsServiceAccount = Boolean.parseBoolean(filterConfig.getInitParameter(ENFORCE_ACT_SUB_IS_SERVICE_ACCOUNT_PARAM)); - ingressSourceSpiffeIdExceptionEnabled = Boolean.parseBoolean( - filterConfig.getInitParameter(INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM)); - ingressSourceSpiffeIdsSkipTrustDomainMatch = Boolean.parseBoolean( - filterConfig.getInitParameter(INGRESS_SOURCE_SPIFFE_IDS_SKIP_TRUST_DOMAIN_MATCH_PARAM)); - ingressSourceSpiffeIdsSkipValidation = Boolean.parseBoolean( - filterConfig.getInitParameter(INGRESS_SOURCE_SPIFFE_IDS_SKIP_VALIDATION_PARAM)); + ingressSourceRoutingEnabled = Boolean.parseBoolean( + filterConfig.getInitParameter(INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM)); + ingressSourceRoutingSkipTrustDomainMatch = Boolean.parseBoolean( + filterConfig.getInitParameter(INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_TRUST_DOMAIN_MATCH_PARAM)); + ingressSourceRoutingSkipValidation = Boolean.parseBoolean( + filterConfig.getInitParameter(INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_VALIDATION_PARAM)); - // Parsed strictly whether or not the exception is enabled, so a malformed entry fails the + // Parsed strictly whether or not routing is enabled, so a malformed entry fails the // topology at startup instead of sitting inert. final String ingressSourceSpiffeIdsParam = blankToNull(filterConfig.getInitParameter(INGRESS_SOURCE_SPIFFE_IDS_PARAM)); @@ -551,15 +555,15 @@ public void init(final FilterConfig filterConfig) throws Exception { SOURCE_SPIFFE_ID_HEADER_PARAM)); } - if (ingressSourceSpiffeIdExceptionEnabled) { + if (ingressSourceRoutingEnabled) { if (sourceSpiffeIdHeader == null) { throw new ServletException(String.format(Locale.ROOT, - "%s is true but %s is not configured", INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM, + "%s is true but %s is not configured", INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM, SOURCE_SPIFFE_ID_HEADER_PARAM)); } if (ingressSourceSpiffeIds.isEmpty()) { throw new ServletException(String.format(Locale.ROOT, - "%s is true but %s is not configured", INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM, + "%s is true but %s is not configured", INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM, INGRESS_SOURCE_SPIFFE_IDS_PARAM)); } } @@ -570,13 +574,13 @@ public AudienceValidationResult validate(final HttpServletRequest request, final final List configuredAudiences) { final boolean hasActClaim = token.getClaimAsObject(JWTToken.ACT_CLAIM) != null; - // Resolved before the early returns below because the ingress-source decision needs it. An + // Resolved before the early returns below because the routing decision needs it. An // absent value is not rejected here: that rejection happens further down, after the destination // header and server-name checks, so a request whose source header is missing or unparseable // is reported only once those earlier checks have passed. final Optional sourceSpiffeId = sourceSpiffeIdHeader == null ? Optional.empty() : sourceSpiffeIdFromHeader(request); - final boolean fromIngressSource = ingressSourceSpiffeIdExceptionEnabled + final boolean fromIngressSource = ingressSourceRoutingEnabled && isConfiguredIngressSource(sourceSpiffeId); if (!hasActClaim) { @@ -585,7 +589,7 @@ public AudienceValidationResult validate(final HttpServletRequest request, final // so its audience is either not checked at all or checked against the fixed configured // list. Both decisions are made before the aud claim is required to be present, because // the fixed-list check has its own handling for a token that carries no aud claim. - return ingressSourceSpiffeIdsSkipValidation + return ingressSourceRoutingSkipValidation ? AudienceValidationResult.of(true) : AudienceValidationResult.of( AbstractJWTFilter.matchesConfiguredAudiences(token, configuredAudiences)); @@ -912,11 +916,11 @@ private Optional actSubMatchFailureReason(final Map most /** * Whether {@code sourceSpiffeId} names one of the configured ingress gateway identities. The * namespace and service account must match an entry exactly; the trust domain must match too - * unless {@link #INGRESS_SOURCE_SPIFFE_IDS_SKIP_TRUST_DOMAIN_MATCH_PARAM} is enabled. An absent - * value, which is what a missing or unparseable source header produces, names no ingress - * identity and is reported as no match here rather than as a failure, so such a request - * continues to the ordinary flow and is rejected there by the existing requirement that a - * configured header be present and parseable. + * unless {@link #INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_TRUST_DOMAIN_MATCH_PARAM} is + * enabled. An absent value, which is what a missing or unparseable source header produces, + * names no ingress identity and is reported as no match here rather than as a failure, so such + * a request is not routed and continues to the ordinary flow, where the rules for a missing or + * unparseable source header apply. */ private boolean isConfiguredIngressSource(final Optional sourceSpiffeId) { if (sourceSpiffeId.isEmpty()) { @@ -926,7 +930,7 @@ private boolean isConfiguredIngressSource(final Optional sourceSpiffeI for (final SpiffeId allowed : ingressSourceSpiffeIds) { if (allowed.namespace().equals(source.namespace()) && allowed.serviceAccount().equals(source.serviceAccount()) - && (ingressSourceSpiffeIdsSkipTrustDomainMatch + && (ingressSourceRoutingSkipTrustDomainMatch || allowed.trustDomain().equals(source.trustDomain()))) { return true; } diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sIngressSourceExceptionTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sIngressSourceExceptionTest.java deleted file mode 100644 index 112c4aa1a..000000000 --- a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sIngressSourceExceptionTest.java +++ /dev/null @@ -1,566 +0,0 @@ -/* - * 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.provider.federation.jwt.filter; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; - -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; - -import org.junit.Test; - -import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.actorChain; -import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.delegationToken; -import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.delegationTokenWithActClaim; -import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.filterConfig; -import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.nonDelegationToken; -import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.requestWithHeaders; - -/** - * Covers the ingress-source exception: the configuration it requires at initialization, which - * tokens it applies to, the two forms the exemption can take (no audience validation at all, or - * validation against the fixed configured allowed-audience list), how an allowed ingress identity - * is matched against the request's source SPIFFE id, and the rejection of a token carrying an - * {@code act} claim that arrives from an ingress source. - */ -public class K8sIngressSourceExceptionTest { - - private static final String SPIFFE_HEADER = "x-dest-spiffe-id"; - private static final String SOURCE_SPIFFE_HEADER = "x-source-spiffe-id"; - private static final String PATH_HEADER = "x-dest-path"; - private static final String SERVER_NAME_HEADER = "x-dest-server-name"; - private static final String CLUSTER_DOMAIN = "cluster.local"; - private static final String CLUSTER_SUFFIX = ".svc.cluster.local"; - private static final String DEST_SPIFFE_VALUE = "spiffe://trust-domain/ns/ns/sa/dest-sa"; - private static final String INGRESS_SPIFFE_ID = "spiffe://trust-domain/ns/istio-system/sa/ingressgateway"; - private static final String SECOND_INGRESS_SPIFFE_ID = "spiffe://trust-domain/ns/edge/sa/edge-gateway"; - private static final String INGRESS_SPIFFE_OTHER_TRUST_DOMAIN = - "spiffe://other-domain/ns/istio-system/sa/ingressgateway"; - private static final String NON_INGRESS_SPIFFE_ID = "spiffe://trust-domain/ns/ns/sa/caller-sa"; - private static final String MATCHING_AUD = "https://svc.ns"; - private static final String OTHER_NAMESPACE_AUD = "https://svc.other-ns"; - private static final String UNRELATED_AUD = "https://somewhere.else.example/nothing/to/do/with/this"; - private static final String WHITELIST_AUD = "configured-audience"; - private static final String MISSING_SOURCE_MESSAGE = "Missing or unparseable source SPIFFE id in header"; - - private static K8sDestinationAudienceValidator init(final Map params) throws Exception { - final K8sDestinationAudienceValidator validator = new K8sDestinationAudienceValidator(); - validator.init(filterConfig(params)); - return validator; - } - - private static Map baseParams() { - final Map params = new HashMap<>(); - params.put(K8sDestinationAudienceValidator.NAMESPACE_FROM_SPIFFE_ID_HEADER_PARAM, SPIFFE_HEADER); - params.put(K8sDestinationAudienceValidator.SOURCE_SPIFFE_ID_HEADER_PARAM, SOURCE_SPIFFE_HEADER); - params.put(K8sDestinationAudienceValidator.CLUSTER_DOMAINS_PARAM, CLUSTER_DOMAIN); - return params; - } - - private static Map exceptionParams() { - final Map params = baseParams(); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM, "true"); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID); - return params; - } - - private static Map skipValidationParams() { - final Map params = exceptionParams(); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_SKIP_VALIDATION_PARAM, "true"); - return params; - } - - private static Map headersFrom(final String sourceSpiffeId) { - final Map headers = new HashMap<>(); - headers.put(SPIFFE_HEADER, DEST_SPIFFE_VALUE); - if (sourceSpiffeId != null) { - headers.put(SOURCE_SPIFFE_HEADER, sourceSpiffeId); - } - return headers; - } - - private static HttpServletRequest requestFrom(final String sourceSpiffeId) { - return requestWithHeaders(headersFrom(sourceSpiffeId)); - } - - // ---- init() requirements ---- - - @Test - public void testInitThrowsWhenExceptionEnabledButSourceHeaderUnconfigured() { - final Map params = new HashMap<>(); - params.put(K8sDestinationAudienceValidator.NAMESPACE_FROM_SPIFFE_ID_HEADER_PARAM, SPIFFE_HEADER); - params.put(K8sDestinationAudienceValidator.CLUSTER_DOMAINS_PARAM, CLUSTER_DOMAIN); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM, "true"); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID); - final ServletException e = assertThrows(ServletException.class, () -> init(params)); - assertTrue(e.getMessage().contains( - K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM)); - assertTrue(e.getMessage().contains(K8sDestinationAudienceValidator.SOURCE_SPIFFE_ID_HEADER_PARAM)); - } - - @Test - public void testInitThrowsWhenExceptionEnabledButIngressIdListUnconfigured() { - final Map params = baseParams(); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM, "true"); - final ServletException e = assertThrows(ServletException.class, () -> init(params)); - assertTrue(e.getMessage().contains( - K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM)); - assertTrue(e.getMessage().contains(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM)); - } - - @Test - public void testInitThrowsWhenExceptionEnabledAndIngressIdListIsBlank() { - final Map params = baseParams(); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_EXCEPTION_ENABLED_PARAM, "true"); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, " "); - final ServletException e = assertThrows(ServletException.class, () -> init(params)); - assertTrue(e.getMessage().contains(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM)); - } - - @Test - public void testInitSucceedsWhenExceptionEnabledWithSourceHeaderAndIngressIdList() throws Exception { - init(exceptionParams()); - } - - @Test - public void testInitThrowsOnUnparseableIngressSpiffeIdEntry() { - final Map params = exceptionParams(); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, "not-a-spiffe-id"); - final ServletException e = assertThrows(ServletException.class, () -> init(params)); - assertTrue(e.getMessage().contains(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM)); - assertTrue(e.getMessage().contains("not-a-spiffe-id")); - } - - @Test - public void testInitThrowsOnIngressSpiffeIdEntryWithoutNamespaceAndServiceAccountPath() { - final Map params = exceptionParams(); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, "spiffe://trust-domain/ns/only"); - assertThrows(ServletException.class, () -> init(params)); - } - - @Test - public void testInitThrowsOnIngressSpiffeIdsWithStrayInteriorComma() { - final Map params = exceptionParams(); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, - INGRESS_SPIFFE_ID + ",," + SECOND_INGRESS_SPIFFE_ID); - assertThrows(ServletException.class, () -> init(params)); - } - - @Test - public void testInitThrowsOnIngressSpiffeIdsWithTrailingComma() { - final Map params = exceptionParams(); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID + ","); - assertThrows(ServletException.class, () -> init(params)); - } - - @Test - public void testInitThrowsOnIngressSpiffeIdsWithLeadingComma() { - final Map params = exceptionParams(); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, "," + INGRESS_SPIFFE_ID); - assertThrows(ServletException.class, () -> init(params)); - } - - @Test - public void testInitSucceedsWithTwoIngressSpiffeIdEntries() throws Exception { - final Map params = exceptionParams(); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, - " " + INGRESS_SPIFFE_ID + " , " + SECOND_INGRESS_SPIFFE_ID + " "); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_SKIP_VALIDATION_PARAM, "true"); - final K8sDestinationAudienceValidator validator = init(params); - // Both entries were trimmed on the way in: a source naming either one is exempted. - assertTrue(validator.validate(requestFrom(INGRESS_SPIFFE_ID), nonDelegationToken(OTHER_NAMESPACE_AUD), null) - .isValid()); - assertTrue(validator.validate(requestFrom(SECOND_INGRESS_SPIFFE_ID), - nonDelegationToken(OTHER_NAMESPACE_AUD), null).isValid()); - } - - @Test - public void testInitSucceedsWithIngressIdListConfiguredWhileExceptionDisabled() throws Exception { - final Map params = baseParams(); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID); - init(params); - } - - @Test - public void testInitSucceedsWithSkipFlagsSetWhileExceptionDisabled() throws Exception { - final Map params = baseParams(); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_SKIP_VALIDATION_PARAM, "true"); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_SKIP_TRUST_DOMAIN_MATCH_PARAM, "true"); - init(params); - } - - @Test - public void testInitThrowsOnUnparseableIngressSpiffeIdEntryEvenWhenExceptionDisabled() { - final Map params = baseParams(); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, "not-a-spiffe-id"); - assertThrows(ServletException.class, () -> init(params)); - } - - // ---- exception on, skip.validation at its default of false ---- - - @Test - public void testIngressSourceWithoutActClaimIsValidatedAgainstConfiguredAudiences() throws Exception { - final K8sDestinationAudienceValidator validator = init(exceptionParams()); - assertTrue(validator.validate(requestFrom(INGRESS_SPIFFE_ID), nonDelegationToken(WHITELIST_AUD), - Collections.singletonList(WHITELIST_AUD)).isValid()); - } - - @Test - public void testIngressSourceWithoutActClaimRejectedWhenAudienceNotInConfiguredList() throws Exception { - final K8sDestinationAudienceValidator validator = init(exceptionParams()); - assertFalse(validator.validate(requestFrom(INGRESS_SPIFFE_ID), nonDelegationToken(WHITELIST_AUD), - Collections.singletonList("some-other-audience")).isValid()); - } - - @Test - public void testIngressSourceWithoutActClaimIsNotDestinationValidated() throws Exception { - final K8sDestinationAudienceValidator validator = init(exceptionParams()); - // MATCHING_AUD would pass destination validation but is not in the configured list: the list - // replaces destination matching rather than being tried in addition to it. - assertFalse(validator.validate(requestFrom(INGRESS_SPIFFE_ID), nonDelegationToken(MATCHING_AUD), - Collections.singletonList(WHITELIST_AUD)).isValid()); - } - - @Test - public void testIngressSourceWithoutActClaimAcceptedWhenConfiguredAudiencesNull() throws Exception { - final K8sDestinationAudienceValidator validator = init(exceptionParams()); - // The fixed-list check treats a null configured list as "any audience is acceptable". - assertTrue(validator.validate(requestFrom(INGRESS_SPIFFE_ID), nonDelegationToken(WHITELIST_AUD), null) - .isValid()); - } - - @Test - public void testIngressSourceWithoutActClaimAndNoAudClaimReachesConfiguredAudiences() throws Exception { - final K8sDestinationAudienceValidator validator = init(exceptionParams()); - // Reaching the "NONE" handling in the fixed-list check proves the ingress route runs before this - // validator's own rejection of a token with no aud claim. - assertTrue(validator.validate(requestFrom(INGRESS_SPIFFE_ID), nonDelegationToken((String[]) null), - Collections.singletonList("NONE")).isValid()); - } - - @Test - public void testIngressSourceWithoutActClaimAndNoAudClaimRejectedWithoutNoneSentinel() throws Exception { - final K8sDestinationAudienceValidator validator = init(exceptionParams()); - final AudienceValidationResult result = validator.validate(requestFrom(INGRESS_SPIFFE_ID), - nonDelegationToken((String[]) null), Collections.singletonList(WHITELIST_AUD)); - assertFalse(result.isValid()); - // The fixed-list check carries no message, which also shows this validator's own aud-presence - // rejection did not run. - assertNull(result.message()); - } - - // ---- exception on, skip.validation true ---- - - @Test - public void testSkipValidationAcceptsIngressSourceWithUnrelatedAudience() throws Exception { - final K8sDestinationAudienceValidator validator = init(skipValidationParams()); - assertTrue(validator.validate(requestFrom(INGRESS_SPIFFE_ID), nonDelegationToken(UNRELATED_AUD), - Collections.singletonList(WHITELIST_AUD)).isValid()); - } - - @Test - public void testSkipValidationAcceptsIngressSourceWithNoAudClaimAtAll() throws Exception { - final K8sDestinationAudienceValidator validator = init(skipValidationParams()); - assertTrue(validator.validate(requestFrom(INGRESS_SPIFFE_ID), nonDelegationToken((String[]) null), null) - .isValid()); - assertTrue(validator.validate(requestFrom(INGRESS_SPIFFE_ID), nonDelegationToken((String[]) null), - Collections.singletonList(WHITELIST_AUD)).isValid()); - } - - @Test - public void testSkipValidationResultCarriesNoMessage() throws Exception { - final K8sDestinationAudienceValidator validator = init(skipValidationParams()); - final AudienceValidationResult result = validator.validate(requestFrom(INGRESS_SPIFFE_ID), - nonDelegationToken(UNRELATED_AUD), Collections.singletonList(WHITELIST_AUD)); - assertTrue(result.isValid()); - assertNull(result.message()); - } - - @Test - public void testSkipValidationAcceptsIngressSourceWhenConfiguredPathHeaderIsAbsent() throws Exception { - final Map params = skipValidationParams(); - params.put(K8sDestinationAudienceValidator.PATH_HEADER_PARAM, PATH_HEADER); - final K8sDestinationAudienceValidator validator = init(params); - // The exemption precedes the rule that every configured header be present, so an exempted - // request is accepted even though the configured path header is missing from it. - assertTrue(validator.validate(requestFrom(INGRESS_SPIFFE_ID), nonDelegationToken((String[]) null), null) - .isValid()); - } - - @Test - public void testSkipValidationHasNoEffectWhenExceptionDisabled() throws Exception { - final Map params = baseParams(); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_SKIP_VALIDATION_PARAM, "true"); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID); - final K8sDestinationAudienceValidator validator = init(params); - assertFalse(validator.validate(requestFrom(INGRESS_SPIFFE_ID), nonDelegationToken(OTHER_NAMESPACE_AUD), null) - .isValid()); - } - - // ---- exception on, source is not an ingress identity ---- - - @Test - public void testNonIngressSourceWithoutActClaimIsDestinationValidatedAndAccepted() throws Exception { - final K8sDestinationAudienceValidator validator = init(exceptionParams()); - assertTrue(validator.validate(requestFrom(NON_INGRESS_SPIFFE_ID), nonDelegationToken(MATCHING_AUD), null) - .isValid()); - } - - @Test - public void testNonIngressSourceWithoutActClaimIsDestinationValidatedAndRejected() throws Exception { - final K8sDestinationAudienceValidator validator = init(exceptionParams()); - final AudienceValidationResult result = - validator.validate(requestFrom(NON_INGRESS_SPIFFE_ID), nonDelegationToken(OTHER_NAMESPACE_AUD), null); - assertFalse(result.isValid()); - assertTrue(result.message().contains("does not match the destination")); - } - - @Test - public void testNonIngressSourceWithoutActClaimFallsBackToWhitelistWhenConfigured() throws Exception { - final Map params = exceptionParams(); - params.put(K8sDestinationAudienceValidator.VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM, "false"); - final K8sDestinationAudienceValidator validator = init(params); - assertTrue(validator.validate(requestFrom(NON_INGRESS_SPIFFE_ID), nonDelegationToken(WHITELIST_AUD), - Collections.singletonList(WHITELIST_AUD)).isValid()); - } - - @Test - public void testExceptionEnabledWithMissingSourceHeaderIsRejected() throws Exception { - final K8sDestinationAudienceValidator validator = init(exceptionParams()); - final AudienceValidationResult result = - validator.validate(requestFrom(null), nonDelegationToken(MATCHING_AUD), null); - assertFalse(result.isValid()); - assertTrue(result.message().contains(MISSING_SOURCE_MESSAGE)); - } - - @Test - public void testExceptionEnabledWithUnparseableSourceHeaderIsRejected() throws Exception { - final K8sDestinationAudienceValidator validator = init(exceptionParams()); - final AudienceValidationResult result = - validator.validate(requestFrom("not-a-spiffe-id"), nonDelegationToken(MATCHING_AUD), null); - assertFalse(result.isValid()); - assertTrue(result.message().contains(MISSING_SOURCE_MESSAGE)); - } - - @Test - public void testMissingSourceHeaderFallsBackToWhitelistWhenValidateWithoutActClaimFalse() throws Exception { - final Map params = exceptionParams(); - params.put(K8sDestinationAudienceValidator.VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM, "false"); - final K8sDestinationAudienceValidator validator = init(params); - assertTrue(validator.validate(requestFrom(null), nonDelegationToken(WHITELIST_AUD), - Collections.singletonList(WHITELIST_AUD)).isValid()); - } - - @Test - public void testSkipValidationAcceptsIngressSourceWhenValidateWithoutActClaimFalse() throws Exception { - final Map params = skipValidationParams(); - params.put(K8sDestinationAudienceValidator.VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM, "false"); - final K8sDestinationAudienceValidator validator = init(params); - assertTrue(validator.validate(requestFrom(INGRESS_SPIFFE_ID), nonDelegationToken(UNRELATED_AUD), - Collections.singletonList(WHITELIST_AUD)).isValid()); - } - - @Test - public void testSourceMatchingNoneOfSeveralConfiguredIngressIdsIsNotExempted() throws Exception { - final Map params = skipValidationParams(); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, - INGRESS_SPIFFE_ID + "," + SECOND_INGRESS_SPIFFE_ID); - final K8sDestinationAudienceValidator validator = init(params); - assertFalse(validator.validate(requestFrom(NON_INGRESS_SPIFFE_ID), nonDelegationToken(OTHER_NAMESPACE_AUD), - null).isValid()); - } - - @Test - public void testSourceMatchingTheSecondConfiguredIngressIdIsExempted() throws Exception { - final Map params = skipValidationParams(); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, - INGRESS_SPIFFE_ID + "," + SECOND_INGRESS_SPIFFE_ID); - final K8sDestinationAudienceValidator validator = init(params); - assertTrue(validator.validate(requestFrom(SECOND_INGRESS_SPIFFE_ID), nonDelegationToken(OTHER_NAMESPACE_AUD), - null).isValid()); - } - - // ---- trust-domain matching ---- - - @Test - public void testDifferentTrustDomainIsNotAnIngressSourceByDefault() throws Exception { - final K8sDestinationAudienceValidator validator = init(skipValidationParams()); - assertFalse(validator.validate(requestFrom(INGRESS_SPIFFE_OTHER_TRUST_DOMAIN), - nonDelegationToken(OTHER_NAMESPACE_AUD), null).isValid()); - } - - @Test - public void testDifferentTrustDomainIsAnIngressSourceWhenTrustDomainMatchSkipped() throws Exception { - final Map params = skipValidationParams(); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_SKIP_TRUST_DOMAIN_MATCH_PARAM, "true"); - final K8sDestinationAudienceValidator validator = init(params); - assertTrue(validator.validate(requestFrom(INGRESS_SPIFFE_OTHER_TRUST_DOMAIN), - nonDelegationToken(OTHER_NAMESPACE_AUD), null).isValid()); - } - - @Test - public void testSkippingTrustDomainMatchStillRequiresNamespaceToMatch() throws Exception { - final Map params = skipValidationParams(); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_SKIP_TRUST_DOMAIN_MATCH_PARAM, "true"); - final K8sDestinationAudienceValidator validator = init(params); - assertFalse(validator.validate(requestFrom("spiffe://other-domain/ns/other-ns/sa/ingressgateway"), - nonDelegationToken(OTHER_NAMESPACE_AUD), null).isValid()); - } - - @Test - public void testSkippingTrustDomainMatchStillRequiresServiceAccountToMatch() throws Exception { - final Map params = skipValidationParams(); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_SKIP_TRUST_DOMAIN_MATCH_PARAM, "true"); - final K8sDestinationAudienceValidator validator = init(params); - assertFalse(validator.validate(requestFrom("spiffe://other-domain/ns/istio-system/sa/not-the-gateway"), - nonDelegationToken(OTHER_NAMESPACE_AUD), null).isValid()); - } - - @Test - public void testIngressSpiffeIdMatchingIsCaseInsensitive() throws Exception { - final Map mixedCaseConfigured = skipValidationParams(); - mixedCaseConfigured.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, - "spiffe://Trust-Domain/ns/Istio-System/sa/IngressGateway"); - assertTrue(init(mixedCaseConfigured).validate(requestFrom(INGRESS_SPIFFE_ID), - nonDelegationToken(OTHER_NAMESPACE_AUD), null).isValid()); - - assertTrue(init(skipValidationParams()).validate( - requestFrom("spiffe://Trust-Domain/ns/Istio-System/sa/IngressGateway"), - nonDelegationToken(OTHER_NAMESPACE_AUD), null).isValid()); - } - - @Test - public void testSkipTrustDomainMatchHasNoEffectWhenExceptionDisabled() throws Exception { - final Map params = baseParams(); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_SKIP_TRUST_DOMAIN_MATCH_PARAM, "true"); - final K8sDestinationAudienceValidator validator = init(params); - assertFalse(validator.validate(requestFrom(INGRESS_SPIFFE_OTHER_TRUST_DOMAIN), - nonDelegationToken(OTHER_NAMESPACE_AUD), null).isValid()); - } - - // ---- a token with an act claim from an ingress source ---- - - @Test - public void testDelegationTokenFromIngressSourceIsRejected() throws Exception { - final K8sDestinationAudienceValidator validator = init(exceptionParams()); - final AudienceValidationResult result = - validator.validate(requestFrom(INGRESS_SPIFFE_ID), delegationToken(MATCHING_AUD), null); - assertFalse(result.isValid()); - assertTrue(result.message().contains("act claim")); - assertTrue(result.message().contains("ingress")); - assertTrue(result.message().contains(SOURCE_SPIFFE_HEADER)); - } - - @Test - public void testDelegationTokenFromIngressSourceIsRejectedEvenWhenSkipValidationTrue() throws Exception { - final K8sDestinationAudienceValidator validator = init(skipValidationParams()); - assertFalse(validator.validate(requestFrom(INGRESS_SPIFFE_ID), delegationToken(MATCHING_AUD), null) - .isValid()); - } - - @Test - public void testDelegationTokenFromIngressSourceIsRejectedWithNoAudClaim() throws Exception { - final K8sDestinationAudienceValidator validator = init(exceptionParams()); - final AudienceValidationResult result = - validator.validate(requestFrom(INGRESS_SPIFFE_ID), delegationToken((String[]) null), null); - assertFalse(result.isValid()); - assertTrue(result.message().contains("ingress")); - assertFalse(result.message().contains("Token has an act claim but no aud claim")); - } - - @Test - public void testDelegationTokenFromIngressSourceIsAcceptedWhenExceptionDisabled() throws Exception { - final Map params = baseParams(); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID); - final K8sDestinationAudienceValidator validator = init(params); - assertTrue(validator.validate(requestFrom(INGRESS_SPIFFE_ID), delegationToken(MATCHING_AUD), null).isValid()); - } - - @Test - public void testDelegationTokenFromNonIngressSourceIsDestinationValidated() throws Exception { - final K8sDestinationAudienceValidator validator = init(exceptionParams()); - assertTrue(validator.validate(requestFrom(NON_INGRESS_SPIFFE_ID), delegationToken(MATCHING_AUD), null) - .isValid()); - } - - @Test - public void testMalformedActClaimFromIngressSourceIsRejected() throws Exception { - final K8sDestinationAudienceValidator validator = init(exceptionParams()); - final AudienceValidationResult result = validator.validate(requestFrom(INGRESS_SPIFFE_ID), - delegationTokenWithActClaim("not-a-map", MATCHING_AUD), null); - assertFalse(result.isValid()); - assertTrue(result.message().contains("ingress")); - } - - @Test - public void testDelegationTokenFromIngressSourceRejectedBeforeActSubBindingRuns() throws Exception { - final Map params = exceptionParams(); - params.put(K8sDestinationAudienceValidator.ENFORCE_ACT_SUB_MATCHES_SOURCE_SPIFFE_ID_PARAM, "true"); - final K8sDestinationAudienceValidator validator = init(params); - // The act sub names the ingress service account, so the binding check on its own would pass. - final AudienceValidationResult result = validator.validate(requestFrom(INGRESS_SPIFFE_ID), - delegationTokenWithActClaim(actorChain("system:serviceaccount:istio-system:ingressgateway"), - MATCHING_AUD), null); - assertFalse(result.isValid()); - assertTrue(result.message().contains("ingress")); - } - - // ---- the destination-validation checks that follow the routing ---- - - @Test - public void testRequireAllAudiencesMatchStillAppliesWhenExceptionEnabledButNotIngress() throws Exception { - final Map params = exceptionParams(); - params.put(K8sDestinationAudienceValidator.REQUIRE_ALL_AUDIENCES_MATCH_PARAM, "true"); - final K8sDestinationAudienceValidator validator = init(params); - assertFalse(validator.validate(requestFrom(NON_INGRESS_SPIFFE_ID), - delegationToken(MATCHING_AUD, OTHER_NAMESPACE_AUD), null).isValid()); - } - - @Test - public void testRequireAllAudiencesMatchDoesNotApplyToTheWhitelistRoute() throws Exception { - final Map params = exceptionParams(); - params.put(K8sDestinationAudienceValidator.REQUIRE_ALL_AUDIENCES_MATCH_PARAM, "true"); - final K8sDestinationAudienceValidator validator = init(params); - // The fixed-list check accepts a token when any one of its audiences is in the list. - assertTrue(validator.validate(requestFrom(INGRESS_SPIFFE_ID), - nonDelegationToken(WHITELIST_AUD, UNRELATED_AUD), Arrays.asList(WHITELIST_AUD, "another-audience")) - .isValid()); - } - - @Test - public void testBothNamespaceSourcesStillMustAgreeWhenExceptionEnabledButNotIngress() throws Exception { - final Map params = exceptionParams(); - params.put(K8sDestinationAudienceValidator.SERVER_NAME_HEADER_PARAM, SERVER_NAME_HEADER); - final K8sDestinationAudienceValidator validator = init(params); - final Map headers = headersFrom(NON_INGRESS_SPIFFE_ID); - headers.put(SERVER_NAME_HEADER, "svc.other-ns" + CLUSTER_SUFFIX); - final AudienceValidationResult result = - validator.validate(requestWithHeaders(headers), nonDelegationToken(MATCHING_AUD), null); - assertFalse(result.isValid()); - assertTrue(result.message().contains("disagrees with namespace")); - } -} diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sIngressSourceRoutingTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sIngressSourceRoutingTest.java new file mode 100644 index 000000000..9d9301a57 --- /dev/null +++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sIngressSourceRoutingTest.java @@ -0,0 +1,603 @@ +/* + * 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.provider.federation.jwt.filter; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; + +import org.apache.knox.gateway.services.security.token.impl.JWT; +import org.junit.Test; + +import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.actorChain; +import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.delegationToken; +import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.delegationTokenWithActClaim; +import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.filterConfig; +import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.nonDelegationToken; +import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.requestWithHeaders; + +/** + * Covers ingress source routing: the configuration it requires at initialization, which + * requests and tokens it applies to, the two audience checks a token with no {@code act} claim can + * be routed to (none at all, or the fixed configured allowed-audience list), how a configured + * ingress identity is matched against the request's source SPIFFE id, and the rejection of a token + * carrying an {@code act} claim that arrives from an ingress source. + * + *

Every test that names a route proves it, by showing a result that only that route can + * produce. Three routes are possible for a token that passes through here, and each helper + * below checks one of them with inputs chosen so the other two would give a different answer: + *

+ * The configured list is always non-null where a route is being proven, because a null list + * accepts every audience and would make the list route indistinguishable from the others. How the + * list check itself treats every combination of list and token audience is covered by + * {@link MatchesConfiguredAudiencesTest}; here it is only shown to be reached. + * + *

The destination in every request is the namespace {@code ns}, which {@link #MATCHING_AUD} + * names. It comes from the destination SPIFFE id header configured in {@link #baseParams()}, + * which {@link #headersFrom(String)} always sets to {@link #DEST_SPIFFE_VALUE}. + */ +public class K8sIngressSourceRoutingTest { + + private static final String SPIFFE_HEADER = "x-dest-spiffe-id"; + private static final String SOURCE_SPIFFE_HEADER = "x-source-spiffe-id"; + private static final String PATH_HEADER = "x-dest-path"; + private static final String SERVER_NAME_HEADER = "x-dest-server-name"; + private static final String CLUSTER_DOMAIN = "cluster.local"; + private static final String CLUSTER_SUFFIX = ".svc.cluster.local"; + private static final String DEST_SPIFFE_VALUE = "spiffe://trust-domain/ns/ns/sa/dest-sa"; + private static final String INGRESS_SPIFFE_ID = "spiffe://trust-domain/ns/istio-system/sa/ingressgateway"; + private static final String SECOND_INGRESS_SPIFFE_ID = "spiffe://trust-domain/ns/edge/sa/edge-gateway"; + private static final String INGRESS_SPIFFE_OTHER_TRUST_DOMAIN = + "spiffe://other-domain/ns/istio-system/sa/ingressgateway"; + private static final String NON_INGRESS_SPIFFE_ID = "spiffe://trust-domain/ns/ns/sa/caller-sa"; + + /** Names the destination namespace {@code ns}: accepted by destination validation. */ + private static final String MATCHING_AUD = "https://svc.ns"; + /** Names another namespace, so destination validation rejects it; it is the configured list. */ + private static final String LISTED_AUD = "https://svc.other-ns"; + /** Neither the destination nor in the configured list. */ + private static final String UNRELATED_AUD = "https://somewhere.else.example/nothing/to/do/with/this"; + private static final List CONFIGURED_AUDIENCES = Collections.singletonList(LISTED_AUD); + + private static final String DESTINATION_MISMATCH_MESSAGE = "does not match the destination"; + private static final String MISSING_SOURCE_MESSAGE = "Missing or unparseable source SPIFFE id in header"; + private static final String INGRESS_DELEGATION_MESSAGE = "is a configured ingress source"; + + private static final Function NON_DELEGATION = audiences -> nonDelegationToken(audiences); + private static final Function DELEGATION = audiences -> delegationToken(audiences); + + private static K8sDestinationAudienceValidator init(final Map params) throws Exception { + final K8sDestinationAudienceValidator validator = new K8sDestinationAudienceValidator(); + validator.init(filterConfig(params)); + return validator; + } + + /** + * Destination validation set up: the destination namespace is read from the SPIFFE id header + * {@link #SPIFFE_HEADER}, and {@link #CLUSTER_DOMAIN} is the cluster domain. Also names the + * header that carries the source SPIFFE id. No ingress routing is configured. + */ + private static Map baseParams() { + final Map params = new HashMap<>(); + params.put(K8sDestinationAudienceValidator.NAMESPACE_FROM_SPIFFE_ID_HEADER_PARAM, SPIFFE_HEADER); + params.put(K8sDestinationAudienceValidator.SOURCE_SPIFFE_ID_HEADER_PARAM, SOURCE_SPIFFE_HEADER); + params.put(K8sDestinationAudienceValidator.CLUSTER_DOMAINS_PARAM, CLUSTER_DOMAIN); + return params; + } + + /** {@link #baseParams()} plus ingress source routing on, with {@link #INGRESS_SPIFFE_ID} the ingress. */ + private static Map ingressRoutingParams() { + final Map params = baseParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM, "true"); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID); + return params; + } + + /** {@link #ingressRoutingParams()} plus skipping audience validation for the ingress route. */ + private static Map skipValidationParams() { + final Map params = ingressRoutingParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_VALIDATION_PARAM, "true"); + return params; + } + + /** {@link #baseParams()} with the ingress list and both skip flags set but routing left off. */ + private static Map routingDisabledParams() { + final Map params = baseParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_VALIDATION_PARAM, "true"); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_TRUST_DOMAIN_MATCH_PARAM, + "true"); + return params; + } + + /** Request headers: destination is always {@link #DEST_SPIFFE_VALUE}; the source is omitted when null. */ + private static Map headersFrom(final String sourceSpiffeId) { + final Map headers = new HashMap<>(); + headers.put(SPIFFE_HEADER, DEST_SPIFFE_VALUE); + if (sourceSpiffeId != null) { + headers.put(SOURCE_SPIFFE_HEADER, sourceSpiffeId); + } + return headers; + } + + private static HttpServletRequest requestFrom(final String sourceSpiffeId) { + return requestWithHeaders(headersFrom(sourceSpiffeId)); + } + + private static AudienceValidationResult validate(final K8sDestinationAudienceValidator validator, + final String sourceSpiffeId, final JWT token, final List configuredAudiences) { + return validator.validate(requestFrom(sourceSpiffeId), token, configuredAudiences); + } + + private static void assertRejected(final AudienceValidationResult result, final String messageFragment) { + assertFalse(result.isValid()); + assertNotNull("a rejection from this validator carries a message", result.message()); + assertTrue(result.message(), result.message().contains(messageFragment)); + } + + private static void assertAccepted(final AudienceValidationResult result) { + assertTrue(result.message(), result.isValid()); + } + + /** + * Asserts the request is destination-validated: {@link #MATCHING_AUD} is accepted although it + * is not in the configured list, and {@link #LISTED_AUD} is rejected although it is in it. + */ + private static void assertDestinationValidated(final K8sDestinationAudienceValidator validator, + final String sourceSpiffeId, final Function tokens) { + assertAccepted(validate(validator, sourceSpiffeId, tokens.apply(new String[]{MATCHING_AUD}), + CONFIGURED_AUDIENCES)); + assertRejected(validate(validator, sourceSpiffeId, tokens.apply(new String[]{LISTED_AUD}), + CONFIGURED_AUDIENCES), DESTINATION_MISMATCH_MESSAGE); + } + + /** + * Asserts the token is checked against the fixed configured list and nothing else: + * {@link #LISTED_AUD} is accepted although the destination does not match it, and + * {@link #MATCHING_AUD} is rejected although the destination does match it. The list check's + * rejection carries no message, unlike any rejection from this validator. + */ + private static void assertConfiguredAudiencesChecked(final K8sDestinationAudienceValidator validator, + final String sourceSpiffeId) { + assertAccepted(validate(validator, sourceSpiffeId, nonDelegationToken(LISTED_AUD), CONFIGURED_AUDIENCES)); + final AudienceValidationResult rejected = + validate(validator, sourceSpiffeId, nonDelegationToken(MATCHING_AUD), CONFIGURED_AUDIENCES); + assertFalse(rejected.isValid()); + assertNull(rejected.message()); + } + + /** + * Asserts audience validation is skipped: {@link #UNRELATED_AUD}, which is neither the + * destination nor in the configured list, is accepted, and so is a token with no audience at + * all, which the configured list (it has no {@code NONE}) and destination validation both reject. + */ + private static void assertAudienceValidationSkipped(final K8sDestinationAudienceValidator validator, + final String sourceSpiffeId) { + assertAccepted(validate(validator, sourceSpiffeId, nonDelegationToken(UNRELATED_AUD), CONFIGURED_AUDIENCES)); + assertAccepted(validate(validator, sourceSpiffeId, nonDelegationToken((String[]) null), + CONFIGURED_AUDIENCES)); + } + + private static void assertIngressDelegationRejected(final AudienceValidationResult result) { + assertRejected(result, INGRESS_DELEGATION_MESSAGE); + assertTrue(result.message(), result.message().contains("act claim")); + assertTrue(result.message(), result.message().contains(SOURCE_SPIFFE_HEADER)); + } + + private static void assertInitRejectsIngressIds(final Map params) { + final ServletException e = assertThrows(ServletException.class, () -> init(params)); + assertTrue(e.getMessage(), + e.getMessage().contains(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM)); + } + + // ---- init() requirements ---- + + @Test + public void testInitThrowsWhenRoutingEnabledButSourceHeaderUnconfigured() { + final Map params = new HashMap<>(); + params.put(K8sDestinationAudienceValidator.NAMESPACE_FROM_SPIFFE_ID_HEADER_PARAM, SPIFFE_HEADER); + params.put(K8sDestinationAudienceValidator.CLUSTER_DOMAINS_PARAM, CLUSTER_DOMAIN); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM, "true"); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID); + final ServletException e = assertThrows(ServletException.class, () -> init(params)); + assertTrue(e.getMessage().contains( + K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM)); + assertTrue(e.getMessage().contains(K8sDestinationAudienceValidator.SOURCE_SPIFFE_ID_HEADER_PARAM)); + } + + @Test + public void testInitThrowsWhenRoutingEnabledButIngressIdListUnconfigured() { + final Map params = baseParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM, "true"); + final ServletException e = assertThrows(ServletException.class, () -> init(params)); + assertTrue(e.getMessage().contains( + K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM)); + assertTrue(e.getMessage().contains(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM)); + } + + @Test + public void testInitThrowsWhenRoutingEnabledAndIngressIdListIsBlank() { + final Map params = baseParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM, "true"); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, " "); + final ServletException e = assertThrows(ServletException.class, () -> init(params)); + assertTrue(e.getMessage().contains(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM)); + } + + @Test + public void testInitThrowsOnUnparseableIngressSpiffeIdEntry() { + final Map params = ingressRoutingParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, "not-a-spiffe-id"); + final ServletException e = assertThrows(ServletException.class, () -> init(params)); + assertTrue(e.getMessage().contains(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM)); + assertTrue(e.getMessage().contains("not-a-spiffe-id")); + } + + @Test + public void testInitThrowsOnIngressSpiffeIdEntryWithoutNamespaceAndServiceAccountPath() { + final Map params = ingressRoutingParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, "spiffe://trust-domain/ns/only"); + assertInitRejectsIngressIds(params); + } + + @Test + public void testInitThrowsOnIngressSpiffeIdsWithStrayInteriorComma() { + final Map params = ingressRoutingParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, + INGRESS_SPIFFE_ID + ",," + SECOND_INGRESS_SPIFFE_ID); + assertInitRejectsIngressIds(params); + } + + @Test + public void testInitThrowsOnIngressSpiffeIdsWithTrailingComma() { + final Map params = ingressRoutingParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID + ","); + assertInitRejectsIngressIds(params); + } + + @Test + public void testInitThrowsOnIngressSpiffeIdsWithLeadingComma() { + final Map params = ingressRoutingParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, "," + INGRESS_SPIFFE_ID); + assertInitRejectsIngressIds(params); + } + + @Test + public void testInitTrimsEachOfTwoIngressSpiffeIdEntries() throws Exception { + final Map params = skipValidationParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, + " " + INGRESS_SPIFFE_ID + " , " + SECOND_INGRESS_SPIFFE_ID + " "); + final K8sDestinationAudienceValidator validator = init(params); + assertAudienceValidationSkipped(validator, INGRESS_SPIFFE_ID); + assertAudienceValidationSkipped(validator, SECOND_INGRESS_SPIFFE_ID); + } + + @Test + public void testInitSucceedsWithIngressIdListConfiguredWhileRoutingDisabled() throws Exception { + final Map params = baseParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID); + init(params); + } + + @Test + public void testInitSucceedsWithRoutingSkipFlagsSetWhileRoutingDisabled() throws Exception { + final Map params = baseParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_VALIDATION_PARAM, "true"); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_TRUST_DOMAIN_MATCH_PARAM, + "true"); + init(params); + } + + @Test + public void testInitThrowsOnUnparseableIngressSpiffeIdEntryEvenWhenRoutingDisabled() { + final Map params = baseParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, "not-a-spiffe-id"); + assertInitRejectsIngressIds(params); + } + + // ---- routing on, skip.validation at its default of false: ingress source, no act claim ---- + + @Test + public void testIngressSourceWithoutActClaimIsCheckedAgainstConfiguredAudiencesOnly() throws Exception { + assertConfiguredAudiencesChecked(init(ingressRoutingParams()), INGRESS_SPIFFE_ID); + } + + @Test + public void testIngressSourceWithoutActClaimIsUnrestrictedWhenNoAudiencesAreConfigured() throws Exception { + final K8sDestinationAudienceValidator validator = init(ingressRoutingParams()); + // A null configured list makes the list check accept any audience, so on this route the + // ingress is only as strict as the deployment's configured audiences... + assertAccepted(validate(validator, INGRESS_SPIFFE_ID, nonDelegationToken(UNRELATED_AUD), null)); + // ...whereas any other source with the same token is destination-validated and rejected. + assertRejected(validate(validator, NON_INGRESS_SPIFFE_ID, nonDelegationToken(UNRELATED_AUD), null), + DESTINATION_MISMATCH_MESSAGE); + } + + @Test + public void testIngressSourceWithoutActClaimAndNoAudClaimFollowsTheConfiguredListNoneRule() throws Exception { + final K8sDestinationAudienceValidator validator = init(ingressRoutingParams()); + final JWT noAudience = nonDelegationToken((String[]) null); + // No NONE in the list: rejected by the list check, which carries no message, so this + // validator's own "token has no aud claim" rejection never ran. + final AudienceValidationResult withoutNone = validate(validator, INGRESS_SPIFFE_ID, noAudience, + CONFIGURED_AUDIENCES); + assertFalse(withoutNone.isValid()); + assertNull(withoutNone.message()); + // NONE in the list: accepted, which destination validation never does for a token with no aud. + assertAccepted(validate(validator, INGRESS_SPIFFE_ID, noAudience, Arrays.asList(LISTED_AUD, "NONE"))); + // Contrast: any other source with the same token is rejected by this validator, with a message. + assertRejected(validate(validator, NON_INGRESS_SPIFFE_ID, noAudience, Arrays.asList(LISTED_AUD, "NONE")), + "no aud claim"); + } + + // ---- routing on, skip.validation true: ingress source, no act claim ---- + + @Test + public void testSkipValidationSkipsAudienceValidationForIngressSourceWithoutActClaim() throws Exception { + assertAudienceValidationSkipped(init(skipValidationParams()), INGRESS_SPIFFE_ID); + } + + @Test + public void testSkipValidationPrecedesTheRequirementThatConfiguredHeadersBePresent() throws Exception { + final Map params = skipValidationParams(); + params.put(K8sDestinationAudienceValidator.PATH_HEADER_PARAM, PATH_HEADER); + final K8sDestinationAudienceValidator validator = init(params); + // The configured path header is missing from the request, yet the skipping route accepts it... + assertAudienceValidationSkipped(validator, INGRESS_SPIFFE_ID); + // ...while the same missing header is a rejection for any other source. + assertRejected(validate(validator, NON_INGRESS_SPIFFE_ID, nonDelegationToken(MATCHING_AUD), null), + PATH_HEADER); + } + + @Test + public void testSkipValidationHasNoEffectWhenRoutingDisabled() throws Exception { + // The ingress list and skip.validation are set, but routing is off: an ingress source is + // destination-validated like any other. + assertDestinationValidated(init(routingDisabledParams()), INGRESS_SPIFFE_ID, NON_DELEGATION); + } + + @Test + public void testSkipValidationStillSkipsWhenValidateWithoutActClaimFalse() throws Exception { + final Map params = skipValidationParams(); + params.put(K8sDestinationAudienceValidator.VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM, "false"); + assertAudienceValidationSkipped(init(params), INGRESS_SPIFFE_ID); + } + + // ---- routing on, source is not an ingress identity ---- + + @Test + public void testNonIngressSourceWithoutActClaimIsDestinationValidated() throws Exception { + assertDestinationValidated(init(ingressRoutingParams()), NON_INGRESS_SPIFFE_ID, NON_DELEGATION); + } + + @Test + public void testNonIngressSourceWithoutActClaimIsCheckedAgainstConfiguredAudiencesWhenValidateWithoutActClaimFalse() + throws Exception { + final Map params = ingressRoutingParams(); + params.put(K8sDestinationAudienceValidator.VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM, "false"); + assertConfiguredAudiencesChecked(init(params), NON_INGRESS_SPIFFE_ID); + } + + @Test + public void testMissingSourceHeaderIsNotRoutedAndIsRejectedAsMissing() throws Exception { + final K8sDestinationAudienceValidator validator = init(skipValidationParams()); + assertRejected(validate(validator, null, nonDelegationToken(MATCHING_AUD), CONFIGURED_AUDIENCES), + MISSING_SOURCE_MESSAGE); + } + + @Test + public void testUnparseableSourceHeaderIsNotRoutedAndIsRejectedAsMissing() throws Exception { + final K8sDestinationAudienceValidator validator = init(skipValidationParams()); + assertRejected(validate(validator, "not-a-spiffe-id", nonDelegationToken(MATCHING_AUD), CONFIGURED_AUDIENCES), + MISSING_SOURCE_MESSAGE); + } + + @Test + public void testMissingSourceHeaderIsCheckedAgainstConfiguredAudiencesWhenValidateWithoutActClaimFalse() + throws Exception { + final Map params = skipValidationParams(); + params.put(K8sDestinationAudienceValidator.VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM, "false"); + // Not skipped, although skip.validation is true: with no source header there is no ingress. + assertConfiguredAudiencesChecked(init(params), null); + } + + @Test + public void testSourceMatchingNoneOfSeveralConfiguredIngressIdsIsDestinationValidated() throws Exception { + final Map params = skipValidationParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, + INGRESS_SPIFFE_ID + "," + SECOND_INGRESS_SPIFFE_ID); + assertDestinationValidated(init(params), NON_INGRESS_SPIFFE_ID, NON_DELEGATION); + } + + @Test + public void testSourceMatchingTheSecondConfiguredIngressIdIsRouted() throws Exception { + final Map params = skipValidationParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, + INGRESS_SPIFFE_ID + "," + SECOND_INGRESS_SPIFFE_ID); + assertAudienceValidationSkipped(init(params), SECOND_INGRESS_SPIFFE_ID); + } + + // ---- trust-domain matching ---- + + @Test + public void testDifferentTrustDomainIsNotAnIngressSourceByDefault() throws Exception { + assertDestinationValidated(init(skipValidationParams()), INGRESS_SPIFFE_OTHER_TRUST_DOMAIN, NON_DELEGATION); + } + + @Test + public void testDifferentTrustDomainIsAnIngressSourceWhenTrustDomainMatchSkipped() throws Exception { + final Map params = skipValidationParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_TRUST_DOMAIN_MATCH_PARAM, + "true"); + assertAudienceValidationSkipped(init(params), INGRESS_SPIFFE_OTHER_TRUST_DOMAIN); + } + + @Test + public void testSkippingTrustDomainMatchStillRequiresNamespaceToMatch() throws Exception { + final Map params = skipValidationParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_TRUST_DOMAIN_MATCH_PARAM, + "true"); + assertDestinationValidated(init(params), "spiffe://other-domain/ns/other-ns/sa/ingressgateway", NON_DELEGATION); + } + + @Test + public void testSkippingTrustDomainMatchStillRequiresServiceAccountToMatch() throws Exception { + final Map params = skipValidationParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_TRUST_DOMAIN_MATCH_PARAM, + "true"); + assertDestinationValidated(init(params), "spiffe://other-domain/ns/istio-system/sa/not-the-gateway", + NON_DELEGATION); + } + + @Test + public void testIngressSpiffeIdMatchingIsCaseInsensitive() throws Exception { + final Map mixedCaseConfigured = skipValidationParams(); + mixedCaseConfigured.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, + "spiffe://Trust-Domain/ns/Istio-System/sa/IngressGateway"); + assertAudienceValidationSkipped(init(mixedCaseConfigured), INGRESS_SPIFFE_ID); + + assertAudienceValidationSkipped(init(skipValidationParams()), + "spiffe://Trust-Domain/ns/Istio-System/sa/IngressGateway"); + } + + @Test + public void testSkipTrustDomainMatchHasNoEffectWhenRoutingDisabled() throws Exception { + assertDestinationValidated(init(routingDisabledParams()), INGRESS_SPIFFE_OTHER_TRUST_DOMAIN, NON_DELEGATION); + } + + // ---- a token with an act claim from an ingress source ---- + + @Test + public void testDelegationTokenFromIngressSourceIsRejected() throws Exception { + final K8sDestinationAudienceValidator validator = init(ingressRoutingParams()); + assertIngressDelegationRejected(validate(validator, INGRESS_SPIFFE_ID, delegationToken(MATCHING_AUD), null)); + } + + @Test + public void testDelegationTokenFromIngressSourceIsNotRoutedToConfiguredAudiences() throws Exception { + final K8sDestinationAudienceValidator validator = init(ingressRoutingParams()); + // LISTED_AUD is in the configured list, which would accept a token with no act claim. + assertIngressDelegationRejected( + validate(validator, INGRESS_SPIFFE_ID, delegationToken(LISTED_AUD), CONFIGURED_AUDIENCES)); + } + + @Test + public void testDelegationTokenFromIngressSourceIsRejectedEvenWhenSkipValidationTrue() throws Exception { + final K8sDestinationAudienceValidator validator = init(skipValidationParams()); + assertIngressDelegationRejected(validate(validator, INGRESS_SPIFFE_ID, delegationToken(MATCHING_AUD), null)); + } + + @Test + public void testDelegationTokenFromIngressSourceIsRejectedAsIngressWhenItHasNoAudClaim() throws Exception { + final K8sDestinationAudienceValidator validator = init(ingressRoutingParams()); + // The rejection for a missing aud claim would otherwise have been reported first. + assertIngressDelegationRejected(validate(validator, INGRESS_SPIFFE_ID, delegationToken((String[]) null), null)); + assertRejected(validate(validator, NON_INGRESS_SPIFFE_ID, delegationToken((String[]) null), null), + "Token has an act claim but no aud claim"); + } + + @Test + public void testDelegationTokenFromIngressSourceIsDestinationValidatedWhenRoutingDisabled() throws Exception { + final Map params = baseParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID); + assertDestinationValidated(init(params), INGRESS_SPIFFE_ID, DELEGATION); + } + + @Test + public void testDelegationTokenFromNonIngressSourceIsDestinationValidated() throws Exception { + assertDestinationValidated(init(ingressRoutingParams()), NON_INGRESS_SPIFFE_ID, DELEGATION); + } + + @Test + public void testMalformedActClaimFromIngressSourceIsRejectedAsIngress() throws Exception { + final K8sDestinationAudienceValidator validator = init(ingressRoutingParams()); + assertIngressDelegationRejected(validate(validator, INGRESS_SPIFFE_ID, + delegationTokenWithActClaim("not-a-map", MATCHING_AUD), null)); + } + + @Test + public void testDelegationTokenFromIngressSourceRejectedBeforeActSubBindingRuns() throws Exception { + final Map params = ingressRoutingParams(); + params.put(K8sDestinationAudienceValidator.ENFORCE_ACT_SUB_MATCHES_SOURCE_SPIFFE_ID_PARAM, "true"); + final K8sDestinationAudienceValidator validator = init(params); + // The act sub names a different service account than the source, so if the binding check ran + // first the rejection would be about the actor mismatch rather than about the ingress. + assertIngressDelegationRejected(validate(validator, INGRESS_SPIFFE_ID, + delegationTokenWithActClaim(actorChain("system:serviceaccount:other-ns:other-sa"), + MATCHING_AUD), null)); + // Contrast: the same token from a non-ingress source is rejected for the actor mismatch. + assertRejected(validate(validator, NON_INGRESS_SPIFFE_ID, + delegationTokenWithActClaim(actorChain("system:serviceaccount:other-ns:other-sa"), + MATCHING_AUD), null), "does not match source SPIFFE id service account"); + } + + // ---- the destination-validation checks that follow the routing ---- + + @Test + public void testRequireAllAudiencesMatchStillAppliesWhenRoutingEnabledButNotIngress() throws Exception { + final Map params = ingressRoutingParams(); + final JWT matchingAndOtherNamespace = delegationToken(MATCHING_AUD, LISTED_AUD); + // Without the flag one matching audience is enough... + assertAccepted(validate(init(params), NON_INGRESS_SPIFFE_ID, matchingAndOtherNamespace, null)); + // ...with it, the second audience, which names another namespace, causes a rejection. + params.put(K8sDestinationAudienceValidator.REQUIRE_ALL_AUDIENCES_MATCH_PARAM, "true"); + assertRejected(validate(init(params), NON_INGRESS_SPIFFE_ID, matchingAndOtherNamespace, null), + DESTINATION_MISMATCH_MESSAGE); + } + + @Test + public void testRequireAllAudiencesMatchDoesNotApplyToTheConfiguredAudiencesCheck() throws Exception { + final Map params = ingressRoutingParams(); + params.put(K8sDestinationAudienceValidator.REQUIRE_ALL_AUDIENCES_MATCH_PARAM, "true"); + final K8sDestinationAudienceValidator validator = init(params); + // UNRELATED_AUD is in neither the list nor the destination, so a require-all check would + // reject this token; the list check accepts it because one audience is in the list. + assertAccepted(validate(validator, INGRESS_SPIFFE_ID, nonDelegationToken(LISTED_AUD, UNRELATED_AUD), + CONFIGURED_AUDIENCES)); + } + + @Test + public void testBothNamespaceSourcesStillMustAgreeWhenRoutingEnabledButNotIngress() throws Exception { + final Map params = ingressRoutingParams(); + params.put(K8sDestinationAudienceValidator.SERVER_NAME_HEADER_PARAM, SERVER_NAME_HEADER); + final K8sDestinationAudienceValidator validator = init(params); + final Map headers = headersFrom(NON_INGRESS_SPIFFE_ID); + headers.put(SERVER_NAME_HEADER, "svc.other-ns" + CLUSTER_SUFFIX); + assertRejected(validator.validate(requestWithHeaders(headers), nonDelegationToken(MATCHING_AUD), null), + "disagrees with namespace"); + } +} diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/MatchesConfiguredAudiencesTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/MatchesConfiguredAudiencesTest.java index 0b5af22bd..6b3373e5a 100644 --- a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/MatchesConfiguredAudiencesTest.java +++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/MatchesConfiguredAudiencesTest.java @@ -78,4 +78,31 @@ public void testNoTokenAudienceWithoutNoneConfiguredIsInvalid() { final List configuredAudiences = Collections.singletonList("bar"); assertFalse(AbstractJWTFilter.matchesConfiguredAudiences(jwtToken, configuredAudiences)); } + + @Test + public void testNoConfiguredAudiencesIsValidWhenTokenHasNoAudience() { + final JWT jwtToken = EasyMock.createNiceMock(JWT.class); + EasyMock.expect(jwtToken.getAudienceClaims()).andReturn(null).anyTimes(); + EasyMock.replay(jwtToken); + + assertTrue(AbstractJWTFilter.matchesConfiguredAudiences(jwtToken, null)); + } + + @Test + public void testSingleTokenAudienceMatchingOnlyConfiguredAudienceIsValid() { + final JWT jwtToken = EasyMock.createNiceMock(JWT.class); + EasyMock.expect(jwtToken.getAudienceClaims()).andReturn(new String[]{"A"}).anyTimes(); + EasyMock.replay(jwtToken); + + assertTrue(AbstractJWTFilter.matchesConfiguredAudiences(jwtToken, Collections.singletonList("A"))); + } + + @Test + public void testNoneConfiguredDoesNotAcceptANonMatchingTokenAudience() { + final JWT jwtToken = EasyMock.createNiceMock(JWT.class); + EasyMock.expect(jwtToken.getAudienceClaims()).andReturn(new String[]{"foo"}).anyTimes(); + EasyMock.replay(jwtToken); + + assertFalse(AbstractJWTFilter.matchesConfiguredAudiences(jwtToken, Collections.singletonList("NONE"))); + } } From af207490d50678bfe40b921a9f0e9da9c8ea7484 Mon Sep 17 00:00:00 2001 From: Harrison Date: Wed, 28 Oct 2026 16:44:40 -0700 Subject: [PATCH 4/5] Refactor test. Final commit message for the code changes: KNOX-3534 - Add ingress source routing to the k8s destination audience validator Tokens that reach the destination validator through an ingress gateway carry an audience that does not name an in-cluster destination, so the destination checks cannot be applied to them. Add an opt-in routing step, decided by the source SPIFFE id header, that sends such tokens to a different audience check. New parameters (all read by K8sDestinationAudienceValidator.init): - ingress.source.spiffe-id.routing.enabled (default false): master switch. - ingress.source.spiffe-ids: comma-separated ingress SPIFFE ids. Each entry is trimmed and must parse; an unparseable entry fails init even when routing is disabled, so a typo cannot lie dormant until routing is enabled. - ingress.source.spiffe-ids.routing.skip.trust.domain.match (default false): match an ingress id on namespace and service account only, ignoring trust domain. - ingress.source.spiffe-ids.routing.skip.validation (default false): see below. init() requirements: enabling routing without the source SPIFFE id header parameter, or without a non-blank ingress id list, fails topology startup naming the missing parameter. The two skip flags are inert when routing is disabled; with routing disabled every token is validated exactly as before. Behavior when routing is enabled and the request's source SPIFFE id matches a configured ingress id (matching is case-insensitive): - Token without an act claim, skip.validation=false: accepted only if its audience is in the configured knox.token.audiences list, using AbstractJWTFilter.matchesConfiguredAudiences (a null list accepts all, NONE admits a token with no aud, NONE is not a wildcard). A rejection from this check carries no message, unlike destination rejections. - Token without an act claim, skip.validation=true: accepted with no audience check. - Token with an act claim: rejected with a message naming the act claim, the ingress source and the source header. A delegation token authorizes a must not arrive through the ingress. Skip flags do not exempt it, and it is rejected before the act-sub binding check. A malformed act claim from an ingress source is rejected the same way. Both ingress routes run before the aud-presence and header-presence checks because the configured-list check has its own handling of a token with no aud. A request whose source header is missing or unparseable is not an ingress source and takes the existing flow, including the existing missing-source rejection. With validate.audiences.without.act.claim=false that flow already returns the configured-list result first, and this change does not alter it. Security note: the source SPIFFE id header is trusted at face value. Whoever can set it can have a request routed as ingress, so it must be set or overwritten by a trusted proxy. The configured-list route is only as strong as knox.token.audiences. The source SPIFFE id header is preexisting, just now used in another optional flow. Tests: K8sIngressSourceRoutingTest covers init requirements, each route proved by inputs that only that route accepts, trust-domain matching, the act claim rejection and its ordering, and that require-all-audiences-match and the namespace agreement check still apply to non-ingress sources. MatchesConfiguredAudiencesTest gains cases for a null configured list with a token without aud, a single matching audience, and NONE not matching a non-matching audience. --- .../filter/K8sIngressSourceRoutingTest.java | 349 ++++++++++-------- 1 file changed, 195 insertions(+), 154 deletions(-) diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sIngressSourceRoutingTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sIngressSourceRoutingTest.java index 9d9301a57..94139bdb0 100644 --- a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sIngressSourceRoutingTest.java +++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sIngressSourceRoutingTest.java @@ -46,59 +46,79 @@ /** * Covers ingress source routing: the configuration it requires at initialization, which * requests and tokens it applies to, the two audience checks a token with no {@code act} claim can - * be routed to (none at all, or the fixed configured allowed-audience list), how a configured + * be sent to (none at all, or the fixed configured allowed-audience list), how a configured * ingress identity is matched against the request's source SPIFFE id, and the rejection of a token * carrying an {@code act} claim that arrives from an ingress source. * - *

Every test that names a route proves it, by showing a result that only that route can - * produce. Three routes are possible for a token that passes through here, and each helper - * below checks one of them with inputs chosen so the other two would give a different answer: - *

- * The configured list is always non-null where a route is being proven, because a null list - * accepts every audience and would make the list route indistinguishable from the others. How the - * list check itself treats every combination of list and token audience is covered by - * {@link MatchesConfiguredAudiencesTest}; here it is only shown to be reached. + *

Every request in this class is for the same destination: the namespace + * {@link #DEST_NAMESPACE}, taken from the destination SPIFFE id header ({@link #DEST_SPIFFE_VALUE}). + * Audiences are written as full cluster DNS names so that the destination namespace is always + * checked against the destination SPIFFE id header and never inferred from the source SPIFFE id + * (that inference is for a namespace-less audience and is covered in + * {@link K8sDestinationAudienceValidatorTest}). Only the source SPIFFE id varies between requests. * - *

The destination in every request is the namespace {@code ns}, which {@link #MATCHING_AUD} - * names. It comes from the destination SPIFFE id header configured in {@link #baseParams()}, - * which {@link #headersFrom(String)} always sets to {@link #DEST_SPIFFE_VALUE}. + *

How a route is proven. A token that reaches the validator is handled by one of three + * routes, and a test that names a route must show a result that only that route produces. These + * are the inputs used and what each route does with them, given {@link #CONFIGURED_AUDIENCES} + * (always non-null, because a null list accepts every audience and would hide the difference): + *

+ *   token aud                          destination      configured list    skipped
+ *   MATCHING_AUD                       accept           reject             accept
+ *   LISTED_AUD                         reject           accept             accept
+ *   UNLISTED_OTHER_NAMESPACE_AUD       reject           reject             accept
+ *   no aud claim                       reject           reject             accept
+ * 
+ * {@code assertDestinationValidated} uses the first two rows, {@code assertConfiguredAudiencesChecked} + * the first two in the opposite sense, and {@code assertAudienceValidationSkipped} the last two, + * which neither of the other routes accepts. + * + *

Assumptions. The table, and so the helpers, rely on how the validator works today: + * these three routes are the only ones, and the audience constants were chosen so the table holds. + * {@link #testRouteProofInputsBehaveAsTheTableStates()} checks the table directly. If it fails, + * or a new route is added to the validator (for example a second allowed-audience list), every + * helper must be revisited and given inputs that the new route answers differently, and the table + * above extended with a column for it. + * + *

How the configured-list check itself treats every combination of list and token audience is + * covered by {@link MatchesConfiguredAudiencesTest}; here it is only shown to be reached. */ public class K8sIngressSourceRoutingTest { - private static final String SPIFFE_HEADER = "x-dest-spiffe-id"; + private static final String DEST_SPIFFE_HEADER = "x-dest-spiffe-id"; private static final String SOURCE_SPIFFE_HEADER = "x-source-spiffe-id"; private static final String PATH_HEADER = "x-dest-path"; private static final String SERVER_NAME_HEADER = "x-dest-server-name"; private static final String CLUSTER_DOMAIN = "cluster.local"; - private static final String CLUSTER_SUFFIX = ".svc.cluster.local"; - private static final String DEST_SPIFFE_VALUE = "spiffe://trust-domain/ns/ns/sa/dest-sa"; + private static final String CLUSTER_SUFFIX = ".svc." + CLUSTER_DOMAIN; + + private static final String DEST_NAMESPACE = "dest-namespace"; + private static final String OTHER_NAMESPACE = "other-namespace"; + private static final String DEST_SERVICE = "dest-service-name"; + private static final String DEST_SPIFFE_VALUE = "spiffe://trust-domain/ns/" + DEST_NAMESPACE + "/sa/dest-sa"; + private static final String INGRESS_SPIFFE_ID = "spiffe://trust-domain/ns/istio-system/sa/ingressgateway"; private static final String SECOND_INGRESS_SPIFFE_ID = "spiffe://trust-domain/ns/edge/sa/edge-gateway"; private static final String INGRESS_SPIFFE_OTHER_TRUST_DOMAIN = "spiffe://other-domain/ns/istio-system/sa/ingressgateway"; - private static final String NON_INGRESS_SPIFFE_ID = "spiffe://trust-domain/ns/ns/sa/caller-sa"; - - /** Names the destination namespace {@code ns}: accepted by destination validation. */ - private static final String MATCHING_AUD = "https://svc.ns"; - /** Names another namespace, so destination validation rejects it; it is the configured list. */ - private static final String LISTED_AUD = "https://svc.other-ns"; - /** Neither the destination nor in the configured list. */ - private static final String UNRELATED_AUD = "https://somewhere.else.example/nothing/to/do/with/this"; + private static final String NON_INGRESS_SPIFFE_ID = "spiffe://trust-domain/ns/caller-namespace/sa/caller-sa"; + + /** Names the destination namespace: accepted by destination validation, not in the configured list. */ + private static final String MATCHING_AUD = "https://" + DEST_SERVICE + "." + DEST_NAMESPACE + CLUSTER_SUFFIX; + /** Names another namespace, so destination validation rejects it; it is the only entry in the configured list. */ + private static final String LISTED_AUD = "https://" + DEST_SERVICE + "." + OTHER_NAMESPACE + CLUSTER_SUFFIX; + /** Names yet another namespace: neither the destination nor in the configured list. */ + private static final String UNLISTED_OTHER_NAMESPACE_AUD = + "https://" + DEST_SERVICE + ".unlisted-namespace" + CLUSTER_SUFFIX; private static final List CONFIGURED_AUDIENCES = Collections.singletonList(LISTED_AUD); private static final String DESTINATION_MISMATCH_MESSAGE = "does not match the destination"; + /** The entry that makes a token with no aud claim acceptable to the configured list check. */ + private static final String NONE_ENTRY = "NONE"; private static final String MISSING_SOURCE_MESSAGE = "Missing or unparseable source SPIFFE id in header"; private static final String INGRESS_DELEGATION_MESSAGE = "is a configured ingress source"; - private static final Function NON_DELEGATION = audiences -> nonDelegationToken(audiences); - private static final Function DELEGATION = audiences -> delegationToken(audiences); + private static final Function NON_DELEGATION = K8sAudienceTestSupport::nonDelegationToken; + private static final Function DELEGATION = K8sAudienceTestSupport::delegationToken; private static K8sDestinationAudienceValidator init(final Map params) throws Exception { final K8sDestinationAudienceValidator validator = new K8sDestinationAudienceValidator(); @@ -107,60 +127,62 @@ private static K8sDestinationAudienceValidator init(final Map pa } /** - * Destination validation set up: the destination namespace is read from the SPIFFE id header - * {@link #SPIFFE_HEADER}, and {@link #CLUSTER_DOMAIN} is the cluster domain. Also names the - * header that carries the source SPIFFE id. No ingress routing is configured. + * Destination validation only: the destination namespace is read from the destination SPIFFE id + * header {@link #DEST_SPIFFE_HEADER}, and {@link #CLUSTER_DOMAIN} is the cluster domain used to + * parse DNS-form audiences. No source SPIFFE id header is configured and no ingress routing is set. */ - private static Map baseParams() { + private static Map destinationValidationParams() { final Map params = new HashMap<>(); - params.put(K8sDestinationAudienceValidator.NAMESPACE_FROM_SPIFFE_ID_HEADER_PARAM, SPIFFE_HEADER); - params.put(K8sDestinationAudienceValidator.SOURCE_SPIFFE_ID_HEADER_PARAM, SOURCE_SPIFFE_HEADER); + params.put(K8sDestinationAudienceValidator.NAMESPACE_FROM_SPIFFE_ID_HEADER_PARAM, DEST_SPIFFE_HEADER); params.put(K8sDestinationAudienceValidator.CLUSTER_DOMAINS_PARAM, CLUSTER_DOMAIN); return params; } - /** {@link #baseParams()} plus ingress source routing on, with {@link #INGRESS_SPIFFE_ID} the ingress. */ + /** + * {@link #destinationValidationParams()} plus the source SPIFFE id header name and ingress source + * routing on, with {@link #INGRESS_SPIFFE_ID} the only ingress identity. + */ private static Map ingressRoutingParams() { - final Map params = baseParams(); + final Map params = destinationValidationParams(); + params.put(K8sDestinationAudienceValidator.SOURCE_SPIFFE_ID_HEADER_PARAM, SOURCE_SPIFFE_HEADER); params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM, "true"); params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID); return params; } - /** {@link #ingressRoutingParams()} plus skipping audience validation for the ingress route. */ - private static Map skipValidationParams() { + /** {@link #ingressRoutingParams()} plus skipping audience validation on the ingress route. */ + private static Map ingressRoutingSkipValidationParams() { final Map params = ingressRoutingParams(); params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_VALIDATION_PARAM, "true"); return params; } - /** {@link #baseParams()} with the ingress list and both skip flags set but routing left off. */ - private static Map routingDisabledParams() { - final Map params = baseParams(); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_VALIDATION_PARAM, "true"); + /** + * {@link #ingressRoutingParams()} with routing explicitly off but the ingress list and both + * routing skip flags set, so any effect of those settings with routing off would show. + */ + private static Map ingressRoutingDisabledParams() { + final Map params = ingressRoutingSkipValidationParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM, "false"); params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_TRUST_DOMAIN_MATCH_PARAM, "true"); return params; } - /** Request headers: destination is always {@link #DEST_SPIFFE_VALUE}; the source is omitted when null. */ - private static Map headersFrom(final String sourceSpiffeId) { + /** Every request goes to the same destination; this is its destination SPIFFE id header. */ + private static Map destinationHeaders() { final Map headers = new HashMap<>(); - headers.put(SPIFFE_HEADER, DEST_SPIFFE_VALUE); - if (sourceSpiffeId != null) { - headers.put(SOURCE_SPIFFE_HEADER, sourceSpiffeId); - } + headers.put(DEST_SPIFFE_HEADER, DEST_SPIFFE_VALUE); return headers; } - private static HttpServletRequest requestFrom(final String sourceSpiffeId) { - return requestWithHeaders(headersFrom(sourceSpiffeId)); - } - - private static AudienceValidationResult validate(final K8sDestinationAudienceValidator validator, - final String sourceSpiffeId, final JWT token, final List configuredAudiences) { - return validator.validate(requestFrom(sourceSpiffeId), token, configuredAudiences); + /** A request to the destination from {@code sourceSpiffeId}; with null, no source header is sent. */ + private static HttpServletRequest requestFromSource(final String sourceSpiffeId) { + final Map headers = destinationHeaders(); + if (sourceSpiffeId != null) { + headers.put(SOURCE_SPIFFE_HEADER, sourceSpiffeId); + } + return requestWithHeaders(headers); } private static void assertRejected(final AudienceValidationResult result, final String messageFragment) { @@ -173,43 +195,36 @@ private static void assertAccepted(final AudienceValidationResult result) { assertTrue(result.message(), result.isValid()); } - /** - * Asserts the request is destination-validated: {@link #MATCHING_AUD} is accepted although it - * is not in the configured list, and {@link #LISTED_AUD} is rejected although it is in it. - */ + /** Destination validation: first two rows of the table, accepting {@link #MATCHING_AUD} only. */ private static void assertDestinationValidated(final K8sDestinationAudienceValidator validator, final String sourceSpiffeId, final Function tokens) { - assertAccepted(validate(validator, sourceSpiffeId, tokens.apply(new String[]{MATCHING_AUD}), - CONFIGURED_AUDIENCES)); - assertRejected(validate(validator, sourceSpiffeId, tokens.apply(new String[]{LISTED_AUD}), - CONFIGURED_AUDIENCES), DESTINATION_MISMATCH_MESSAGE); + assertAccepted(validator.validate(requestFromSource(sourceSpiffeId), + tokens.apply(new String[]{MATCHING_AUD}), CONFIGURED_AUDIENCES)); + assertRejected(validator.validate(requestFromSource(sourceSpiffeId), + tokens.apply(new String[]{LISTED_AUD}), CONFIGURED_AUDIENCES), DESTINATION_MISMATCH_MESSAGE); } /** - * Asserts the token is checked against the fixed configured list and nothing else: - * {@link #LISTED_AUD} is accepted although the destination does not match it, and - * {@link #MATCHING_AUD} is rejected although the destination does match it. The list check's - * rejection carries no message, unlike any rejection from this validator. + * Configured list: first two rows, accepting {@link #LISTED_AUD} only. The list check's + * rejection carries no message, unlike every rejection from this validator. */ private static void assertConfiguredAudiencesChecked(final K8sDestinationAudienceValidator validator, final String sourceSpiffeId) { - assertAccepted(validate(validator, sourceSpiffeId, nonDelegationToken(LISTED_AUD), CONFIGURED_AUDIENCES)); - final AudienceValidationResult rejected = - validate(validator, sourceSpiffeId, nonDelegationToken(MATCHING_AUD), CONFIGURED_AUDIENCES); + assertAccepted(validator.validate(requestFromSource(sourceSpiffeId), + nonDelegationToken(LISTED_AUD), CONFIGURED_AUDIENCES)); + final AudienceValidationResult rejected = validator.validate(requestFromSource(sourceSpiffeId), + nonDelegationToken(MATCHING_AUD), CONFIGURED_AUDIENCES); assertFalse(rejected.isValid()); assertNull(rejected.message()); } - /** - * Asserts audience validation is skipped: {@link #UNRELATED_AUD}, which is neither the - * destination nor in the configured list, is accepted, and so is a token with no audience at - * all, which the configured list (it has no {@code NONE}) and destination validation both reject. - */ + /** Skipped validation: last two rows, which neither of the other routes accepts. */ private static void assertAudienceValidationSkipped(final K8sDestinationAudienceValidator validator, final String sourceSpiffeId) { - assertAccepted(validate(validator, sourceSpiffeId, nonDelegationToken(UNRELATED_AUD), CONFIGURED_AUDIENCES)); - assertAccepted(validate(validator, sourceSpiffeId, nonDelegationToken((String[]) null), - CONFIGURED_AUDIENCES)); + assertAccepted(validator.validate(requestFromSource(sourceSpiffeId), + nonDelegationToken(UNLISTED_OTHER_NAMESPACE_AUD), CONFIGURED_AUDIENCES)); + assertAccepted(validator.validate(requestFromSource(sourceSpiffeId), + nonDelegationToken((String[]) null), CONFIGURED_AUDIENCES)); } private static void assertIngressDelegationRejected(final AudienceValidationResult result) { @@ -224,15 +239,36 @@ private static void assertInitRejectsIngressIds(final Map params e.getMessage().contains(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM)); } + // ---- the table in the class javadoc ---- + + @Test + public void testRouteProofInputsBehaveAsTheTableStates() throws Exception { + // Configured list on its own. + assertFalse(AbstractJWTFilter.matchesConfiguredAudiences(nonDelegationToken(MATCHING_AUD), CONFIGURED_AUDIENCES)); + assertTrue(AbstractJWTFilter.matchesConfiguredAudiences(nonDelegationToken(LISTED_AUD), CONFIGURED_AUDIENCES)); + assertFalse(AbstractJWTFilter.matchesConfiguredAudiences( + nonDelegationToken(UNLISTED_OTHER_NAMESPACE_AUD), CONFIGURED_AUDIENCES)); + assertFalse(AbstractJWTFilter.matchesConfiguredAudiences( + nonDelegationToken((String[]) null), CONFIGURED_AUDIENCES)); + + // Destination validation on its own: routing is not enabled, so there is no other route. + final K8sDestinationAudienceValidator validator = init(destinationValidationParams()); + assertAccepted(validator.validate(requestFromSource(null), nonDelegationToken(MATCHING_AUD), + CONFIGURED_AUDIENCES)); + assertRejected(validator.validate(requestFromSource(null), nonDelegationToken(LISTED_AUD), + CONFIGURED_AUDIENCES), DESTINATION_MISMATCH_MESSAGE); + assertRejected(validator.validate(requestFromSource(null), nonDelegationToken(UNLISTED_OTHER_NAMESPACE_AUD), + CONFIGURED_AUDIENCES), DESTINATION_MISMATCH_MESSAGE); + assertRejected(validator.validate(requestFromSource(null), nonDelegationToken((String[]) null), + CONFIGURED_AUDIENCES), "no aud claim"); + } + // ---- init() requirements ---- @Test public void testInitThrowsWhenRoutingEnabledButSourceHeaderUnconfigured() { - final Map params = new HashMap<>(); - params.put(K8sDestinationAudienceValidator.NAMESPACE_FROM_SPIFFE_ID_HEADER_PARAM, SPIFFE_HEADER); - params.put(K8sDestinationAudienceValidator.CLUSTER_DOMAINS_PARAM, CLUSTER_DOMAIN); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM, "true"); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID); + final Map params = ingressRoutingParams(); + params.remove(K8sDestinationAudienceValidator.SOURCE_SPIFFE_ID_HEADER_PARAM); final ServletException e = assertThrows(ServletException.class, () -> init(params)); assertTrue(e.getMessage().contains( K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM)); @@ -241,8 +277,8 @@ public void testInitThrowsWhenRoutingEnabledButSourceHeaderUnconfigured() { @Test public void testInitThrowsWhenRoutingEnabledButIngressIdListUnconfigured() { - final Map params = baseParams(); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM, "true"); + final Map params = ingressRoutingParams(); + params.remove(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM); final ServletException e = assertThrows(ServletException.class, () -> init(params)); assertTrue(e.getMessage().contains( K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM)); @@ -251,8 +287,7 @@ public void testInitThrowsWhenRoutingEnabledButIngressIdListUnconfigured() { @Test public void testInitThrowsWhenRoutingEnabledAndIngressIdListIsBlank() { - final Map params = baseParams(); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM, "true"); + final Map params = ingressRoutingParams(); params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, " "); final ServletException e = assertThrows(ServletException.class, () -> init(params)); assertTrue(e.getMessage().contains(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM)); @@ -297,8 +332,8 @@ public void testInitThrowsOnIngressSpiffeIdsWithLeadingComma() { } @Test - public void testInitTrimsEachOfTwoIngressSpiffeIdEntries() throws Exception { - final Map params = skipValidationParams(); + public void testIngressSpiffeIdListEntriesAreTrimmed() throws Exception { + final Map params = ingressRoutingSkipValidationParams(); params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, " " + INGRESS_SPIFFE_ID + " , " + SECOND_INGRESS_SPIFFE_ID + " "); final K8sDestinationAudienceValidator validator = init(params); @@ -308,14 +343,14 @@ public void testInitTrimsEachOfTwoIngressSpiffeIdEntries() throws Exception { @Test public void testInitSucceedsWithIngressIdListConfiguredWhileRoutingDisabled() throws Exception { - final Map params = baseParams(); + final Map params = destinationValidationParams(); params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID); init(params); } @Test public void testInitSucceedsWithRoutingSkipFlagsSetWhileRoutingDisabled() throws Exception { - final Map params = baseParams(); + final Map params = destinationValidationParams(); params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_VALIDATION_PARAM, "true"); params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_TRUST_DOMAIN_MATCH_PARAM, "true"); @@ -324,7 +359,7 @@ public void testInitSucceedsWithRoutingSkipFlagsSetWhileRoutingDisabled() throws @Test public void testInitThrowsOnUnparseableIngressSpiffeIdEntryEvenWhenRoutingDisabled() { - final Map params = baseParams(); + final Map params = destinationValidationParams(); params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, "not-a-spiffe-id"); assertInitRejectsIngressIds(params); } @@ -341,10 +376,11 @@ public void testIngressSourceWithoutActClaimIsUnrestrictedWhenNoAudiencesAreConf final K8sDestinationAudienceValidator validator = init(ingressRoutingParams()); // A null configured list makes the list check accept any audience, so on this route the // ingress is only as strict as the deployment's configured audiences... - assertAccepted(validate(validator, INGRESS_SPIFFE_ID, nonDelegationToken(UNRELATED_AUD), null)); + assertAccepted(validator.validate(requestFromSource(INGRESS_SPIFFE_ID), + nonDelegationToken(UNLISTED_OTHER_NAMESPACE_AUD), null)); // ...whereas any other source with the same token is destination-validated and rejected. - assertRejected(validate(validator, NON_INGRESS_SPIFFE_ID, nonDelegationToken(UNRELATED_AUD), null), - DESTINATION_MISMATCH_MESSAGE); + assertRejected(validator.validate(requestFromSource(NON_INGRESS_SPIFFE_ID), + nonDelegationToken(UNLISTED_OTHER_NAMESPACE_AUD), null), DESTINATION_MISMATCH_MESSAGE); } @Test @@ -353,14 +389,15 @@ public void testIngressSourceWithoutActClaimAndNoAudClaimFollowsTheConfiguredLis final JWT noAudience = nonDelegationToken((String[]) null); // No NONE in the list: rejected by the list check, which carries no message, so this // validator's own "token has no aud claim" rejection never ran. - final AudienceValidationResult withoutNone = validate(validator, INGRESS_SPIFFE_ID, noAudience, + final AudienceValidationResult withoutNone = validator.validate(requestFromSource(INGRESS_SPIFFE_ID), noAudience, CONFIGURED_AUDIENCES); assertFalse(withoutNone.isValid()); assertNull(withoutNone.message()); // NONE in the list: accepted, which destination validation never does for a token with no aud. - assertAccepted(validate(validator, INGRESS_SPIFFE_ID, noAudience, Arrays.asList(LISTED_AUD, "NONE"))); + final List listWithNone = Arrays.asList(LISTED_AUD, NONE_ENTRY); + assertAccepted(validator.validate(requestFromSource(INGRESS_SPIFFE_ID), noAudience, listWithNone)); // Contrast: any other source with the same token is rejected by this validator, with a message. - assertRejected(validate(validator, NON_INGRESS_SPIFFE_ID, noAudience, Arrays.asList(LISTED_AUD, "NONE")), + assertRejected(validator.validate(requestFromSource(NON_INGRESS_SPIFFE_ID), noAudience, listWithNone), "no aud claim"); } @@ -368,18 +405,18 @@ public void testIngressSourceWithoutActClaimAndNoAudClaimFollowsTheConfiguredLis @Test public void testSkipValidationSkipsAudienceValidationForIngressSourceWithoutActClaim() throws Exception { - assertAudienceValidationSkipped(init(skipValidationParams()), INGRESS_SPIFFE_ID); + assertAudienceValidationSkipped(init(ingressRoutingSkipValidationParams()), INGRESS_SPIFFE_ID); } @Test - public void testSkipValidationPrecedesTheRequirementThatConfiguredHeadersBePresent() throws Exception { - final Map params = skipValidationParams(); + public void testSkipValidationPrecedesTheRequirementThatTheConfiguredPathHeaderBePresent() throws Exception { + final Map params = ingressRoutingSkipValidationParams(); params.put(K8sDestinationAudienceValidator.PATH_HEADER_PARAM, PATH_HEADER); final K8sDestinationAudienceValidator validator = init(params); // The configured path header is missing from the request, yet the skipping route accepts it... assertAudienceValidationSkipped(validator, INGRESS_SPIFFE_ID); // ...while the same missing header is a rejection for any other source. - assertRejected(validate(validator, NON_INGRESS_SPIFFE_ID, nonDelegationToken(MATCHING_AUD), null), + assertRejected(validator.validate(requestFromSource(NON_INGRESS_SPIFFE_ID), nonDelegationToken(MATCHING_AUD), null), PATH_HEADER); } @@ -387,12 +424,12 @@ public void testSkipValidationPrecedesTheRequirementThatConfiguredHeadersBePrese public void testSkipValidationHasNoEffectWhenRoutingDisabled() throws Exception { // The ingress list and skip.validation are set, but routing is off: an ingress source is // destination-validated like any other. - assertDestinationValidated(init(routingDisabledParams()), INGRESS_SPIFFE_ID, NON_DELEGATION); + assertDestinationValidated(init(ingressRoutingDisabledParams()), INGRESS_SPIFFE_ID, NON_DELEGATION); } @Test public void testSkipValidationStillSkipsWhenValidateWithoutActClaimFalse() throws Exception { - final Map params = skipValidationParams(); + final Map params = ingressRoutingSkipValidationParams(); params.put(K8sDestinationAudienceValidator.VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM, "false"); assertAudienceValidationSkipped(init(params), INGRESS_SPIFFE_ID); } @@ -413,23 +450,23 @@ public void testNonIngressSourceWithoutActClaimIsCheckedAgainstConfiguredAudienc } @Test - public void testMissingSourceHeaderIsNotRoutedAndIsRejectedAsMissing() throws Exception { - final K8sDestinationAudienceValidator validator = init(skipValidationParams()); - assertRejected(validate(validator, null, nonDelegationToken(MATCHING_AUD), CONFIGURED_AUDIENCES), + public void testMissingSourceHeaderIsNotAnIngressSourceAndIsRejectedAsMissing() throws Exception { + final K8sDestinationAudienceValidator validator = init(ingressRoutingSkipValidationParams()); + assertRejected(validator.validate(requestFromSource(null), nonDelegationToken(MATCHING_AUD), CONFIGURED_AUDIENCES), MISSING_SOURCE_MESSAGE); } @Test - public void testUnparseableSourceHeaderIsNotRoutedAndIsRejectedAsMissing() throws Exception { - final K8sDestinationAudienceValidator validator = init(skipValidationParams()); - assertRejected(validate(validator, "not-a-spiffe-id", nonDelegationToken(MATCHING_AUD), CONFIGURED_AUDIENCES), - MISSING_SOURCE_MESSAGE); + public void testUnparseableSourceHeaderIsNotAnIngressSourceAndIsRejectedAsMissing() throws Exception { + final K8sDestinationAudienceValidator validator = init(ingressRoutingSkipValidationParams()); + assertRejected(validator.validate(requestFromSource("not-a-spiffe-id"), nonDelegationToken(MATCHING_AUD), + CONFIGURED_AUDIENCES), MISSING_SOURCE_MESSAGE); } @Test public void testMissingSourceHeaderIsCheckedAgainstConfiguredAudiencesWhenValidateWithoutActClaimFalse() throws Exception { - final Map params = skipValidationParams(); + final Map params = ingressRoutingSkipValidationParams(); params.put(K8sDestinationAudienceValidator.VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM, "false"); // Not skipped, although skip.validation is true: with no source header there is no ingress. assertConfiguredAudiencesChecked(init(params), null); @@ -437,15 +474,15 @@ public void testMissingSourceHeaderIsCheckedAgainstConfiguredAudiencesWhenValida @Test public void testSourceMatchingNoneOfSeveralConfiguredIngressIdsIsDestinationValidated() throws Exception { - final Map params = skipValidationParams(); + final Map params = ingressRoutingSkipValidationParams(); params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID + "," + SECOND_INGRESS_SPIFFE_ID); assertDestinationValidated(init(params), NON_INGRESS_SPIFFE_ID, NON_DELEGATION); } @Test - public void testSourceMatchingTheSecondConfiguredIngressIdIsRouted() throws Exception { - final Map params = skipValidationParams(); + public void testSourceMatchingTheSecondConfiguredIngressIdIsAnIngressSource() throws Exception { + final Map params = ingressRoutingSkipValidationParams(); params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID + "," + SECOND_INGRESS_SPIFFE_ID); assertAudienceValidationSkipped(init(params), SECOND_INGRESS_SPIFFE_ID); @@ -455,12 +492,13 @@ public void testSourceMatchingTheSecondConfiguredIngressIdIsRouted() throws Exce @Test public void testDifferentTrustDomainIsNotAnIngressSourceByDefault() throws Exception { - assertDestinationValidated(init(skipValidationParams()), INGRESS_SPIFFE_OTHER_TRUST_DOMAIN, NON_DELEGATION); + assertDestinationValidated(init(ingressRoutingSkipValidationParams()), + INGRESS_SPIFFE_OTHER_TRUST_DOMAIN, NON_DELEGATION); } @Test public void testDifferentTrustDomainIsAnIngressSourceWhenTrustDomainMatchSkipped() throws Exception { - final Map params = skipValidationParams(); + final Map params = ingressRoutingSkipValidationParams(); params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_TRUST_DOMAIN_MATCH_PARAM, "true"); assertAudienceValidationSkipped(init(params), INGRESS_SPIFFE_OTHER_TRUST_DOMAIN); @@ -468,15 +506,16 @@ public void testDifferentTrustDomainIsAnIngressSourceWhenTrustDomainMatchSkipped @Test public void testSkippingTrustDomainMatchStillRequiresNamespaceToMatch() throws Exception { - final Map params = skipValidationParams(); + final Map params = ingressRoutingSkipValidationParams(); params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_TRUST_DOMAIN_MATCH_PARAM, "true"); - assertDestinationValidated(init(params), "spiffe://other-domain/ns/other-ns/sa/ingressgateway", NON_DELEGATION); + assertDestinationValidated(init(params), + "spiffe://other-domain/ns/" + OTHER_NAMESPACE + "/sa/ingressgateway", NON_DELEGATION); } @Test public void testSkippingTrustDomainMatchStillRequiresServiceAccountToMatch() throws Exception { - final Map params = skipValidationParams(); + final Map params = ingressRoutingSkipValidationParams(); params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_TRUST_DOMAIN_MATCH_PARAM, "true"); assertDestinationValidated(init(params), "spiffe://other-domain/ns/istio-system/sa/not-the-gateway", @@ -485,18 +524,18 @@ public void testSkippingTrustDomainMatchStillRequiresServiceAccountToMatch() thr @Test public void testIngressSpiffeIdMatchingIsCaseInsensitive() throws Exception { - final Map mixedCaseConfigured = skipValidationParams(); + final Map mixedCaseConfigured = ingressRoutingSkipValidationParams(); mixedCaseConfigured.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, "spiffe://Trust-Domain/ns/Istio-System/sa/IngressGateway"); assertAudienceValidationSkipped(init(mixedCaseConfigured), INGRESS_SPIFFE_ID); - assertAudienceValidationSkipped(init(skipValidationParams()), + assertAudienceValidationSkipped(init(ingressRoutingSkipValidationParams()), "spiffe://Trust-Domain/ns/Istio-System/sa/IngressGateway"); } @Test public void testSkipTrustDomainMatchHasNoEffectWhenRoutingDisabled() throws Exception { - assertDestinationValidated(init(routingDisabledParams()), INGRESS_SPIFFE_OTHER_TRUST_DOMAIN, NON_DELEGATION); + assertDestinationValidated(init(ingressRoutingDisabledParams()), INGRESS_SPIFFE_OTHER_TRUST_DOMAIN, NON_DELEGATION); } // ---- a token with an act claim from an ingress source ---- @@ -504,37 +543,38 @@ public void testSkipTrustDomainMatchHasNoEffectWhenRoutingDisabled() throws Exce @Test public void testDelegationTokenFromIngressSourceIsRejected() throws Exception { final K8sDestinationAudienceValidator validator = init(ingressRoutingParams()); - assertIngressDelegationRejected(validate(validator, INGRESS_SPIFFE_ID, delegationToken(MATCHING_AUD), null)); + assertIngressDelegationRejected(validator.validate(requestFromSource(INGRESS_SPIFFE_ID), + delegationToken(MATCHING_AUD), null)); } @Test - public void testDelegationTokenFromIngressSourceIsNotRoutedToConfiguredAudiences() throws Exception { + public void testDelegationTokenFromIngressSourceIsNotCheckedAgainstConfiguredAudiences() throws Exception { final K8sDestinationAudienceValidator validator = init(ingressRoutingParams()); // LISTED_AUD is in the configured list, which would accept a token with no act claim. assertIngressDelegationRejected( - validate(validator, INGRESS_SPIFFE_ID, delegationToken(LISTED_AUD), CONFIGURED_AUDIENCES)); + validator.validate(requestFromSource(INGRESS_SPIFFE_ID), delegationToken(LISTED_AUD), CONFIGURED_AUDIENCES)); } @Test public void testDelegationTokenFromIngressSourceIsRejectedEvenWhenSkipValidationTrue() throws Exception { - final K8sDestinationAudienceValidator validator = init(skipValidationParams()); - assertIngressDelegationRejected(validate(validator, INGRESS_SPIFFE_ID, delegationToken(MATCHING_AUD), null)); + final K8sDestinationAudienceValidator validator = init(ingressRoutingSkipValidationParams()); + assertIngressDelegationRejected(validator.validate(requestFromSource(INGRESS_SPIFFE_ID), + delegationToken(MATCHING_AUD), null)); } @Test public void testDelegationTokenFromIngressSourceIsRejectedAsIngressWhenItHasNoAudClaim() throws Exception { final K8sDestinationAudienceValidator validator = init(ingressRoutingParams()); // The rejection for a missing aud claim would otherwise have been reported first. - assertIngressDelegationRejected(validate(validator, INGRESS_SPIFFE_ID, delegationToken((String[]) null), null)); - assertRejected(validate(validator, NON_INGRESS_SPIFFE_ID, delegationToken((String[]) null), null), + assertIngressDelegationRejected(validator.validate(requestFromSource(INGRESS_SPIFFE_ID), + delegationToken((String[]) null), null)); + assertRejected(validator.validate(requestFromSource(NON_INGRESS_SPIFFE_ID), delegationToken((String[]) null), null), "Token has an act claim but no aud claim"); } @Test public void testDelegationTokenFromIngressSourceIsDestinationValidatedWhenRoutingDisabled() throws Exception { - final Map params = baseParams(); - params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID); - assertDestinationValidated(init(params), INGRESS_SPIFFE_ID, DELEGATION); + assertDestinationValidated(init(ingressRoutingDisabledParams()), INGRESS_SPIFFE_ID, DELEGATION); } @Test @@ -545,7 +585,7 @@ public void testDelegationTokenFromNonIngressSourceIsDestinationValidated() thro @Test public void testMalformedActClaimFromIngressSourceIsRejectedAsIngress() throws Exception { final K8sDestinationAudienceValidator validator = init(ingressRoutingParams()); - assertIngressDelegationRejected(validate(validator, INGRESS_SPIFFE_ID, + assertIngressDelegationRejected(validator.validate(requestFromSource(INGRESS_SPIFFE_ID), delegationTokenWithActClaim("not-a-map", MATCHING_AUD), null)); } @@ -556,13 +596,12 @@ public void testDelegationTokenFromIngressSourceRejectedBeforeActSubBindingRuns( final K8sDestinationAudienceValidator validator = init(params); // The act sub names a different service account than the source, so if the binding check ran // first the rejection would be about the actor mismatch rather than about the ingress. - assertIngressDelegationRejected(validate(validator, INGRESS_SPIFFE_ID, - delegationTokenWithActClaim(actorChain("system:serviceaccount:other-ns:other-sa"), - MATCHING_AUD), null)); + final JWT otherActor = delegationTokenWithActClaim( + actorChain("system:serviceaccount:" + OTHER_NAMESPACE + ":other-sa"), MATCHING_AUD); + assertIngressDelegationRejected(validator.validate(requestFromSource(INGRESS_SPIFFE_ID), otherActor, null)); // Contrast: the same token from a non-ingress source is rejected for the actor mismatch. - assertRejected(validate(validator, NON_INGRESS_SPIFFE_ID, - delegationTokenWithActClaim(actorChain("system:serviceaccount:other-ns:other-sa"), - MATCHING_AUD), null), "does not match source SPIFFE id service account"); + assertRejected(validator.validate(requestFromSource(NON_INGRESS_SPIFFE_ID), otherActor, null), + "does not match source SPIFFE id service account"); } // ---- the destination-validation checks that follow the routing ---- @@ -572,11 +611,12 @@ public void testRequireAllAudiencesMatchStillAppliesWhenRoutingEnabledButNotIngr final Map params = ingressRoutingParams(); final JWT matchingAndOtherNamespace = delegationToken(MATCHING_AUD, LISTED_AUD); // Without the flag one matching audience is enough... - assertAccepted(validate(init(params), NON_INGRESS_SPIFFE_ID, matchingAndOtherNamespace, null)); + assertAccepted(init(params).validate( + requestFromSource(NON_INGRESS_SPIFFE_ID), matchingAndOtherNamespace, null)); // ...with it, the second audience, which names another namespace, causes a rejection. params.put(K8sDestinationAudienceValidator.REQUIRE_ALL_AUDIENCES_MATCH_PARAM, "true"); - assertRejected(validate(init(params), NON_INGRESS_SPIFFE_ID, matchingAndOtherNamespace, null), - DESTINATION_MISMATCH_MESSAGE); + assertRejected(init(params).validate( + requestFromSource(NON_INGRESS_SPIFFE_ID), matchingAndOtherNamespace, null), DESTINATION_MISMATCH_MESSAGE); } @Test @@ -584,10 +624,10 @@ public void testRequireAllAudiencesMatchDoesNotApplyToTheConfiguredAudiencesChec final Map params = ingressRoutingParams(); params.put(K8sDestinationAudienceValidator.REQUIRE_ALL_AUDIENCES_MATCH_PARAM, "true"); final K8sDestinationAudienceValidator validator = init(params); - // UNRELATED_AUD is in neither the list nor the destination, so a require-all check would - // reject this token; the list check accepts it because one audience is in the list. - assertAccepted(validate(validator, INGRESS_SPIFFE_ID, nonDelegationToken(LISTED_AUD, UNRELATED_AUD), - CONFIGURED_AUDIENCES)); + // UNLISTED_OTHER_NAMESPACE_AUD is in neither the list nor the destination, so a require-all + // check would reject this token; the list check accepts it because one audience is in the list. + assertAccepted(validator.validate(requestFromSource(INGRESS_SPIFFE_ID), + nonDelegationToken(LISTED_AUD, UNLISTED_OTHER_NAMESPACE_AUD), CONFIGURED_AUDIENCES)); } @Test @@ -595,8 +635,9 @@ public void testBothNamespaceSourcesStillMustAgreeWhenRoutingEnabledButNotIngres final Map params = ingressRoutingParams(); params.put(K8sDestinationAudienceValidator.SERVER_NAME_HEADER_PARAM, SERVER_NAME_HEADER); final K8sDestinationAudienceValidator validator = init(params); - final Map headers = headersFrom(NON_INGRESS_SPIFFE_ID); - headers.put(SERVER_NAME_HEADER, "svc.other-ns" + CLUSTER_SUFFIX); + final Map headers = destinationHeaders(); + headers.put(SOURCE_SPIFFE_HEADER, NON_INGRESS_SPIFFE_ID); + headers.put(SERVER_NAME_HEADER, DEST_SERVICE + "." + OTHER_NAMESPACE + CLUSTER_SUFFIX); assertRejected(validator.validate(requestWithHeaders(headers), nonDelegationToken(MATCHING_AUD), null), "disagrees with namespace"); } From b2b41dc95f42d65650a92ab24c6a4afb338008a4 Mon Sep 17 00:00:00 2001 From: Harrison Date: Wed, 28 Oct 2026 21:39:30 -0700 Subject: [PATCH 5/5] KNOX-3534 - Document ingress source routing in the request audience validator Describe the four request.audience.k8s.ingress.source.* parameters of the Kubernetes destination audience validator in the request audience validator docs: when a request's source SPIFFE id matches a configured ingress source, a token with an act claim is rejected, and a token with no act claim is checked only against the fixed knox.token.audiences list, or not audience-checked at all when skip.validation is true. Adds a parameter table, an "Ingress source routing" section with a full outcome table, startup failure conditions and a trust warning for the source header, and updates the example topology, enforcement table, example request, prerequisites and failure modes. Existing sentences that assumed every token reaches destination validation are corrected. Docs only. --- .../docs/config_request_audience_validator.md | 118 +++++++++++++++--- 1 file changed, 104 insertions(+), 14 deletions(-) diff --git a/knox-site/docs/config_request_audience_validator.md b/knox-site/docs/config_request_audience_validator.md index 422a8d650..3b5e77e85 100644 --- a/knox-site/docs/config_request_audience_validator.md +++ b/knox-site/docs/config_request_audience_validator.md @@ -32,11 +32,11 @@ Leaving `request.audience.validator` unset keeps the fixed-list behavior describ The Kubernetes Destination Audience Validator (`request.audience.k8s.destination.validation`) checks a token's `aud` claim against the request's actual destination rather than against a fixed list. -It always validates a token carrying a delegation `act` claim, i.e. a token that was produced by [RFC 8693](https://www.rfc-editor.org/rfc/rfc8693) token exchange. A token with no `act` claim is validated the same way by default, as described in Non-delegated tokens below; setting `request.audience.k8s.validate.audiences.without.act.claim` to `false` sends such a token to the fixed `knox.token.audiences` check instead. +It validates a token carrying a delegation `act` claim, i.e. a token that was produced by [RFC 8693](https://www.rfc-editor.org/rfc/rfc8693) token exchange, in this way regardless of any other setting, except that with ingress source routing enabled such a token is rejected when it arrives from a configured ingress source (see Ingress source routing below). A token with no `act` claim is validated the same way by default, as described in Non-delegated tokens below; setting `request.audience.k8s.validate.audiences.without.act.claim` to `false` sends such a token to the fixed `knox.token.audiences` check instead. Ingress source routing can also send a token with no `act` claim to that check, or skip audience validation for it, when it arrives from a configured ingress source. ##### Audience format ##### -Each entry in the `aud` claim of a token in scope is expected to be a URL in one of two forms. By default, both forms are tried for every entry, in this order: +Each entry in the `aud` claim of a token that reaches destination validation is expected to be a URL in one of two forms. By default, both forms are tried for every entry, in this order: 1. The **DNS form**, the Kubernetes service DNS name a local caller uses to reach the destination: @@ -88,7 +88,7 @@ A DNS-form entry has no leading segments to skip, since its whole path is always ##### How this validator matches a request to an audience entry ##### -Which of the URL's segments are actually compared against the request depends entirely on which of this validator's header parameters are configured. A segment with no configured source is not compared at all (the DNS form's namespace-less host is the one exception, described above), so an `aud` entry that differs from the request only in an unconfigured segment is accepted. At least one of the destination SPIFFE-id header, the server-name header, or the path header must be configured, or the topology fails to start. +Which of the URL's segments are actually compared against the request depends entirely on which of this validator's header parameters are configured. A segment with no configured source is not compared at all (the DNS form's namespace-less host is the one exception, described above), so an `aud` entry that differs from the request only in an unconfigured segment is accepted. At least one of the destination SPIFFE-id header, the server-name header, or the path header must be configured, or the topology fails to start. The comparisons described in this section apply to a token that reaches destination validation; a token with no `act` claim that ingress source routing sends to another check (see Ingress source routing below) does not reach it. Cluster domain is checked differently for each form, since the two forms carry it differently: @@ -122,7 +122,7 @@ request.audience.k8s.server.name.header.name | HTTP header carrying the destinat request.audience.k8s.server.name.cluster-suffix | Suffix that terminates the FQDN read from the header above, e.g. `.svc.cluster.local`. Only read when that header parameter is itself configured. Independent of `request.audience.k8s.dns.format.cluster.domain` below, which plays the same role for a DNS-form `aud` entry rather than for this header. | `.svc.cluster.local` request.audience.k8s.path.header.name | HTTP header carrying the path to match against the `aud` entry's resource-path segment. | n/a (unset; resource path is then not matched) request.audience.k8s.path.header.from.url | Whether the header above carries a full URL whose path component should be extracted, rather than already being the bare path to match. Only read when that header parameter is itself configured. | `false` -request.audience.k8s.source.spiffe-id.header.name | HTTP header carrying the calling (source) workload's SPIFFE id. Supplies the namespace for a namespace-less DNS-form `aud` entry, and is the identity compared against the actor subject when actor-subject binding is enabled. | n/a (unset; a namespace-less DNS-form entry can then never match, and actor-subject binding cannot be enabled) +request.audience.k8s.source.spiffe-id.header.name | HTTP header carrying the calling (source) workload's SPIFFE id. Supplies the namespace for a namespace-less DNS-form `aud` entry, is the identity compared against the actor subject when actor-subject binding is enabled, and is the value ingress source routing compares against the configured ingress source ids when it is enabled. | n/a (unset; a namespace-less DNS-form entry can then never match, and actor-subject binding and ingress source routing cannot be enabled) ###### DNS-form parameters ###### @@ -143,7 +143,7 @@ request.audience.k8s.audience.path.prefix | Optional path prefix searched for in Name | Description | Default ---------|-------------|-------- request.audience.k8s.require-all-audiences-match | Whether at least one `aud` entry matching the destination is sufficient (`false`, the ordinary "am I an intended audience" semantic of RFC 7519 section 4.1.3), or whether the claim must be non-empty and every entry must match (`true`). See the note below on combining this with the minting-side flags. | `false` -request.audience.k8s.validate.audiences.without.act.claim | Whether a token with no `act` claim is validated against the request destination (`true`) or against the fixed `knox.token.audiences` list (`false`). See Non-delegated tokens below. Has no effect on a token that does carry an `act` claim. | `true` +request.audience.k8s.validate.audiences.without.act.claim | Whether a token with no `act` claim is validated against the request destination (`true`) or against the fixed `knox.token.audiences` list (`false`). See Non-delegated tokens below. Has no effect on a token that does carry an `act` claim, nor on a token with no `act` claim that ingress source routing routes (see Ingress source routing below). | `true` request.audience.k8s.enforce.act.sub.service-account.matches.source.spiffeid | Whether the most recent actor in the token's `act` chain must match the source workload's SPIFFE id. See Actor-subject binding below. Requires `source.spiffe-id.header.name` to be configured; setting this `true` without it fails topology startup. | `false` request.audience.k8s.enforce.act.sub.is.service.account | Whether an actor subject that cannot be read as a k8s service-account subject fails validation (`true`) or passes unchecked (`false`). Only consulted when the parameter above is `true`. | `false` @@ -151,13 +151,59 @@ request.audience.k8s.enforce.act.sub.is.service.account | Whether an actor subje Limiting how many audiences a token carries is a minting-side concern. `JWTFederationFilter` exposes `delegation.enforce.requested.audience.required` and `delegation.enforce.requested.audience.max.one`, which constrain what a delegation token may be minted with. A deployment that wants every delegation token to carry exactly one audience must set both of those; `require-all-audiences-match` is not a substitute for them. Setting it as well is still worthwhile as a destination-side backstop: a multi-audience token that is minted anyway, for example by an older or misconfigured issuer, is accepted only where every one of its audiences names that destination. +###### Ingress source routing parameters ###### + +These control ingress source routing, described in Ingress source routing below. The other three are inert unless `request.audience.k8s.ingress.source.spiffe-id.routing.enabled` is `true`, except that every entry of `request.audience.k8s.ingress.source.spiffe-ids` is validated at startup whether or not routing is enabled. + +Name | Description | Default +---------|-------------|-------- +request.audience.k8s.ingress.source.spiffe-id.routing.enabled | Whether a request that arrives from a configured ingress source is routed to a different audience check than validation against the request destination. When `true`, both `request.audience.k8s.source.spiffe-id.header.name` and `request.audience.k8s.ingress.source.spiffe-ids` must be configured, or topology startup fails. | `false` +request.audience.k8s.ingress.source.spiffe-ids | Comma-separated list of full SPIFFE ids that identify a request as arriving from an ingress source, e.g. `spiffe://example.org/ns/ingress/sa/gateway`. A request matches if the SPIFFE id in the source SPIFFE-id header matches any one entry. Each entry must be a full SPIFFE id of the form `spiffe:///ns//sa/`, even when `skip.trust.domain.match` is `true`; an entry that does not parse, including the empty entry left by a stray, leading or trailing comma, fails topology startup whether or not routing is enabled. Surrounding whitespace on an entry is ignored. | n/a (unset; required when routing is enabled) +request.audience.k8s.ingress.source.spiffe-ids.routing.skip.trust.domain.match | Whether the trust domain of a configured ingress source id is left out of the comparison, so that only the namespace and the service account must match. This relaxes security: any trust domain is then accepted for that namespace and service-account pair, so identities in different trust domains that share the pair become indistinguishable to this check. | `false` (all three portions must match) +request.audience.k8s.ingress.source.spiffe-ids.routing.skip.validation | Whether a token with no `act` claim that arrives from a configured ingress source gets no audience validation at all (`true`), or is checked against the fixed `knox.token.audiences` list (`false`). With `false`, the fixed-list check accepts any `aud` if `knox.token.audiences` is not configured. Has no effect on a token with an `act` claim, on a token that does not arrive from a configured ingress source, or when routing is disabled. | `false` + ##### Path matching ##### An `aud` entry with no resource path, or with a resource path of exactly `/`, matches ANY request path, for either form, including when `request.audience.k8s.path.header.name` is configured and the header is present on the request. This is deliberate, not an oversight: an entry with an empty path is a statement about which destination service a token may be presented to, not about which operations on that service it may invoke. An audience meant to be scoped to one endpoint must carry that endpoint's path. ##### Non-delegated tokens ##### -`request.audience.k8s.validate.audiences.without.act.claim` controls how a token with no `act` claim, that is, a token that is not a delegation token, is handled. It defaults to `true`: such a token's `aud` claim is validated against the request's actual destination in exactly the same way as a delegation token's, and the request is rejected if the token carries no `aud` claim, or an `aud` that is not a valid destination for this request. Setting it to `false` sends a non-delegated token's `aud` claim to the fixed `knox.token.audiences` check (`AbstractJWTFilter.matchesConfiguredAudiences`) instead, the same as if this validator were not selected for it. This parameter has no effect on a token that does carry an `act` claim: that token is always destination-validated by this validator. +`request.audience.k8s.validate.audiences.without.act.claim` controls how a token with no `act` claim, that is, a token that is not a delegation token, is handled. It defaults to `true`: such a token's `aud` claim is validated against the request's actual destination in exactly the same way as a delegation token's, and the request is rejected if the token carries no `aud` claim, or an `aud` that is not a valid destination for this request. Setting it to `false` sends a non-delegated token's `aud` claim to the fixed `knox.token.audiences` check (`AbstractJWTFilter.matchesConfiguredAudiences`) instead, the same as if this validator were not selected for it. This parameter has no effect on a token that does carry an `act` claim: that token is destination-validated by this validator, or rejected if ingress source routing applies to it. Nor does it affect a token with no `act` claim that ingress source routing routes. + +##### Ingress source routing ##### + +A request arrives from an *ingress source* when the SPIFFE id in the header named by `request.audience.k8s.source.spiffe-id.header.name` matches an entry of `request.audience.k8s.ingress.source.spiffe-ids`. An entry matches when the namespace and the service account are equal, and the trust domain is equal too unless `request.audience.k8s.ingress.source.spiffe-ids.routing.skip.trust.domain.match` is `true`. Each portion is compared ignoring case. No wildcard or partial form is supported in any portion. A header that is missing, or does not parse as a SPIFFE id, matches no entry, so such a request is not from an ingress source. + +Ingress source routing is off by default (`request.audience.k8s.ingress.source.spiffe-id.routing.enabled` is `false`), and with it off nothing in this section applies: a request from an ingress source is handled like a request from any other source. When it is on, a request from an ingress source is routed by whether its token carries an `act` claim: + +* **A token with an `act` claim is rejected.** A delegation token authorizes one in-cluster workload to call another, and is not expected to arrive from an ingress source. The rejection does not depend on `skip.validation`, and is decided before any other check, so the token's `aud` claim and the other configured headers are not examined. The request gets the bad-request response, and the log message names the source SPIFFE-id header and its value. +* **A token with no `act` claim is not validated against the request destination.** What it is checked against depends on `request.audience.k8s.ingress.source.spiffe-ids.routing.skip.validation`. When it is `false` (the default), the token's `aud` claim is checked against the fixed `knox.token.audiences` list, the same check `validate.audiences.without.act.claim=false` uses. When it is `true`, no audience check of any kind is made, so a token with no `aud` claim is accepted, and so is one whose `aud` names something unrelated to this request. + +Either way a token with no `act` claim from an ingress source does not reach destination validation, so the destination SPIFFE-id, server-name and path headers are not required for it, none of the matching described under How this validator matches applies, and `validate.audiences.without.act.claim` and `require-all-audiences-match` have no effect on it. + +If `knox.token.audiences` is not configured, the fixed-list check accepts every token, including one with no `aud` claim. If it is configured, a token with an `aud` claim must share at least one entry with it, and a token with no `aud` claim is accepted only if the list contains `NONE`. With routing enabled and `skip.validation` left `false`, a token with no `act` claim from an ingress source is therefore checked only against `knox.token.audiences`; configure that list on the provider to restrict it at all. + +A request that is not from an ingress source, including one whose source SPIFFE-id header is missing or unparseable, is handled exactly as if routing were disabled. A token with an `act` claim is destination-validated. A token with no `act` claim is destination-validated when `validate.audiences.without.act.claim` is `true`, which includes rejecting the request when a configured source SPIFFE-id header is missing or unparseable, or is checked against the fixed list when it is `false`. + +The outcome for every combination of settings: + +Ingress source routing | Token | Source SPIFFE id header | `skip.validation` | `validate.audiences.without.act.claim` | Outcome +---------|---------|---------|---------|---------|--------- +disabled | with `act` claim | any | any | not consulted | Destination validation +disabled | no `act` claim | any | any | `true` | Destination validation +disabled | no `act` claim | any | any | `false` | Fixed list only +enabled | with `act` claim | matches an ingress source | any | not consulted | Rejected +enabled | with `act` claim | matches none, or is missing or unparseable | any | not consulted | Destination validation +enabled | no `act` claim | matches an ingress source | `true` | not consulted | Audience check passes, none is made +enabled | no `act` claim | matches an ingress source | `false` | not consulted | Fixed list only +enabled | no `act` claim | matches none, or is missing or unparseable | any | `true` | Destination validation +enabled | no `act` claim | matches none, or is missing or unparseable | any | `false` | Fixed list only + +"Destination validation" is everything described above in this document. "Fixed list only" is the `knox.token.audiences` check and nothing else. "Rejected" and the audience outcomes concern the audience check only; the other token checks (signature, expiry and so on) still apply to every row. + +Starting the topology fails if routing is enabled and either `request.audience.k8s.source.spiffe-id.header.name` or `request.audience.k8s.ingress.source.spiffe-ids` is not configured, or if any entry of `ingress.source.spiffe-ids` is not a full, parseable SPIFFE id. The entry check runs whether or not routing is enabled. `skip.trust.domain.match` and `skip.validation` have no effect while routing is disabled. + +Routing is driven entirely by the value of the source SPIFFE-id header, which is trusted at face value; see Prerequisites below. A caller able to set that header to a configured ingress source id gets its request routed as an ingress request, and with `skip.validation` set to `true` that request gets no audience check at all. `skip.validation` and `skip.trust.domain.match` each relax a check, and should be enabled only when the header is set or overwritten by a trusted component and the deployment requires it. ##### Actor-subject binding ##### @@ -171,15 +217,15 @@ A token whose `act` claim is present but is not a JSON object, so no actor subje ##### Prerequisites ##### -Each of the four header parameters below (destination SPIFFE-id, server-name, path, and source SPIFFE-id) names a header this validator trusts at face value, with no independent verification. Every one of them **must** be populated only by a component upstream of Knox that is trusted to set or overwrite it correctly with a value it independently determined, never a header an untrusted caller could set or influence. If that guarantee does not hold for a configured header, an untrusted caller controls the corresponding side of the comparison, and the destination binding this validator provides is defeated. Establishing that guarantee is the responsibility of the deployment's network/ingress layer, not something this validator can check. +Each of the four header parameters below (destination SPIFFE-id, server-name, path, and source SPIFFE-id) names a header this validator trusts at face value, with no independent verification. Every one of them **must** be populated only by a component upstream of Knox that is trusted to set or overwrite it correctly with a value it independently determined, never a header an untrusted caller could set or influence. If that guarantee does not hold for a configured header, an untrusted caller controls the corresponding side of the comparison, and the destination binding this validator provides is defeated. Establishing that guarantee is the responsibility of the deployment's network/ingress layer, not something this validator can check. This matters most for the source SPIFFE-id header when ingress source routing is enabled, since the value of that header then decides which audience check a request gets, and with `skip.validation` set to `true` whether it gets one. -When a header parameter is configured, that header must be present and parseable in the shape this validator requires for it on every request, or validation fails, whether or not the `aud` entries actually presented on a given request would have needed that value. This holds for all four header parameters. +When a header parameter is configured, that header must be present and parseable in the shape this validator requires for it on every request that reaches destination validation, or validation fails, whether or not the `aud` entries actually presented on a given request would have needed that value. This holds for all four header parameters. A token with no `act` claim that ingress source routing sends to another check does not reach destination validation, so none of these headers is required for it; only the source SPIFFE-id header is read, to decide the routing. If the path this validator checks can be changed by the surrounding infrastructure after this validator runs (for example, a routing rule that rewrites the request path), configure the path header to carry the path as it was *before* any such rewrite: the audience being validated describes the resource the caller requested, not the path an internal rewrite happens to route it to. ##### Example topology ##### -This example is the combination that validates the destination namespace and path for every token (including non-delegated ones), binds a delegation token's actor to the workload presenting it, and deliberately does not validate service-name. It is written for service DNS audiences such as `https://reporting-svc.analytics.svc.cluster.local/v1/reports`. The custom `aud` form is still enabled since it cannot be disabled. `request.audience.k8s.cluster-domains` is left at its default of `service.local`, so a custom-form entry whose host is exactly `service.local` (for example `https://service.local/analytics/reporting-svc/v1/reports`) is still accepted when it passes the namespace and path checks below, while a custom-form entry for any other host is rejected. Every parameter this validator reads is shown, each marked `(default)` or `(non-default)`, so the full configuration surface, and what it does and does not bind, is visible in one place. Header names are examples; the real ones depend on what the service mesh sets. +This example is the combination that validates the destination namespace and path for every token that reaches destination validation (including non-delegated ones), binds a delegation token's actor to the workload presenting it, deliberately does not validate service-name, and routes requests from one ingress source. It is written for service DNS audiences such as `https://reporting-svc.analytics.svc.cluster.local/v1/reports`. The custom `aud` form is still enabled since it cannot be disabled. `request.audience.k8s.cluster-domains` is left at its default of `service.local`, so a custom-form entry whose host is exactly `service.local` (for example `https://service.local/analytics/reporting-svc/v1/reports`) is still accepted when it passes the namespace and path checks below, while a custom-form entry for any other host is rejected. Every parameter this validator reads is shown, each marked `(default)` or `(non-default)`, so the full configuration surface, and what it does and does not bind, is visible in one place. Header names are examples; the real ones depend on what the service mesh sets. federation @@ -247,7 +293,8 @@ This example is the combination that validates the destination namespace and pat + extends destination checking beyond delegated tokens. A token with no act claim + from the ingress source configured below is not validated this way. --> request.audience.k8s.validate.audiences.without.act.claim true @@ -295,6 +342,42 @@ This example is the combination that validates the destination namespace and pat true + + + request.audience.k8s.ingress.source.spiffe-id.routing.enabled + true + + + + + request.audience.k8s.ingress.source.spiffe-ids + spiffe://example.org/ns/ingress/sa/gateway + + + + + request.audience.k8s.ingress.source.spiffe-ids.routing.skip.trust.domain.match + false + + + + + request.audience.k8s.ingress.source.spiffe-ids.routing.skip.validation + false + +