From 4baea6ba66309f3dad691c9f4ef4a8c6ecae7d09 Mon Sep 17 00:00:00 2001 From: Saurav Date: Mon, 10 Nov 2025 21:52:52 +0000 Subject: [PATCH 1/6] feat(xds): Add configuration objects for ExtAuthz and GrpcService This commit introduces configuration objects for the external authorization (ExtAuthz) filter and the gRPC service it uses. These classes provide a structured, immutable representation of the configuration defined in the xDS protobuf messages. The main new classes are: - `ExtAuthzConfig`: Represents the configuration for the `ExtAuthz` filter, including settings for the gRPC service, header mutation rules, and other filter behaviors. - `GrpcServiceConfig`: Represents the configuration for a gRPC service, including the target URI, credentials, and other settings. - `HeaderMutationRulesConfig`: Represents the configuration for header mutation rules. This commit also includes parsers to create these configuration objects from the corresponding protobuf messages, as well as unit tests for the new classes. --- .../xds/internal/extauthz/ExtAuthzConfig.java | 250 ++++++++++++++ .../extauthz/ExtAuthzParseException.java | 34 ++ .../grpcservice/GrpcServiceConfig.java | 308 ++++++++++++++++++ .../GrpcServiceConfigChannelFactory.java | 26 ++ .../GrpcServiceParseException.java | 33 ++ .../InsecureGrpcChannelFactory.java | 43 +++ .../HeaderMutationRulesConfig.java | 77 +++++ .../internal/extauthz/ExtAuthzConfigTest.java | 259 +++++++++++++++ .../grpcservice/GrpcServiceConfigTest.java | 243 ++++++++++++++ .../InsecureGrpcChannelFactoryTest.java | 57 ++++ .../HeaderMutationRulesConfigTest.java | 84 +++++ 11 files changed, 1414 insertions(+) create mode 100644 xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzConfig.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzParseException.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfig.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigChannelFactory.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceParseException.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/grpcservice/InsecureGrpcChannelFactory.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfig.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzConfigTest.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigTest.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/grpcservice/InsecureGrpcChannelFactoryTest.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfigTest.java diff --git a/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzConfig.java b/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzConfig.java new file mode 100644 index 00000000000..e826f501d9c --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzConfig.java @@ -0,0 +1,250 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.extauthz; + +import com.google.auto.value.AutoValue; +import com.google.common.collect.ImmutableList; +import io.envoyproxy.envoy.config.common.mutation_rules.v3.HeaderMutationRules; +import io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz; +import io.grpc.Status; +import io.grpc.internal.GrpcUtil; +import io.grpc.xds.internal.MatcherParser; +import io.grpc.xds.internal.Matchers; +import io.grpc.xds.internal.grpcservice.GrpcServiceConfig; +import io.grpc.xds.internal.grpcservice.GrpcServiceParseException; +import io.grpc.xds.internal.headermutations.HeaderMutationRulesConfig; +import java.util.Optional; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +/** + * Represents the configuration for the external authorization (ext_authz) filter. This class + * encapsulates the settings defined in the + * {@link io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz} proto, providing a + * structured, immutable representation for use within gRPC. It includes configurations for the gRPC + * service used for authorization, header mutation rules, and other filter behaviors. + */ +@AutoValue +public abstract class ExtAuthzConfig { + + /** Creates a new builder for creating {@link ExtAuthzConfig} instances. */ + public static Builder builder() { + return new AutoValue_ExtAuthzConfig.Builder().allowedHeaders(ImmutableList.of()) + .disallowedHeaders(ImmutableList.of()).statusOnError(Status.PERMISSION_DENIED) + .filterEnabled(Matchers.FractionMatcher.create(100, 100)); + } + + /** + * Parses the {@link io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz} proto to + * create an {@link ExtAuthzConfig} instance. + * + * @param extAuthzProto The ext_authz proto to parse. + * @return An {@link ExtAuthzConfig} instance. + * @throws ExtAuthzParseException if the proto is invalid or contains unsupported features. + */ + public static ExtAuthzConfig fromProto(ExtAuthz extAuthzProto) throws ExtAuthzParseException { + if (!extAuthzProto.hasGrpcService()) { + throw new ExtAuthzParseException( + "unsupported ExtAuthz service type: only grpc_service is " + "supported"); + } + GrpcServiceConfig grpcServiceConfig; + try { + grpcServiceConfig = GrpcServiceConfig.fromProto(extAuthzProto.getGrpcService()); + } catch (GrpcServiceParseException e) { + throw new ExtAuthzParseException("Failed to parse GrpcService config: " + e.getMessage(), e); + } + Builder builder = builder().grpcService(grpcServiceConfig) + .failureModeAllow(extAuthzProto.getFailureModeAllow()) + .failureModeAllowHeaderAdd(extAuthzProto.getFailureModeAllowHeaderAdd()) + .includePeerCertificate(extAuthzProto.getIncludePeerCertificate()) + .denyAtDisable(extAuthzProto.getDenyAtDisable().getDefaultValue().getValue()); + + if (extAuthzProto.hasFilterEnabled()) { + builder.filterEnabled(parsePercent(extAuthzProto.getFilterEnabled().getDefaultValue())); + } + + if (extAuthzProto.hasStatusOnError()) { + builder.statusOnError( + GrpcUtil.httpStatusToGrpcStatus(extAuthzProto.getStatusOnError().getCodeValue())); + } + + if (extAuthzProto.hasAllowedHeaders()) { + builder.allowedHeaders(extAuthzProto.getAllowedHeaders().getPatternsList().stream() + .map(MatcherParser::parseStringMatcher).collect(ImmutableList.toImmutableList())); + } + + if (extAuthzProto.hasDisallowedHeaders()) { + builder.disallowedHeaders(extAuthzProto.getDisallowedHeaders().getPatternsList().stream() + .map(MatcherParser::parseStringMatcher).collect(ImmutableList.toImmutableList())); + } + + if (extAuthzProto.hasDecoderHeaderMutationRules()) { + builder.decoderHeaderMutationRules( + parseHeaderMutationRules(extAuthzProto.getDecoderHeaderMutationRules())); + } + + return builder.build(); + } + + /** + * The gRPC service configuration for the external authorization service. This is a required + * field. + * + * @see ExtAuthz#getGrpcService() + */ + public abstract GrpcServiceConfig grpcService(); + + /** + * Changes the filter's behavior on errors from the authorization service. If {@code true}, the + * filter will accept the request even if the authorization service fails or returns an error. + * + * @see ExtAuthz#getFailureModeAllow() + */ + public abstract boolean failureModeAllow(); + + /** + * Determines if the {@code x-envoy-auth-failure-mode-allowed} header is added to the request when + * {@link #failureModeAllow()} is true. + * + * @see ExtAuthz#getFailureModeAllowHeaderAdd() + */ + public abstract boolean failureModeAllowHeaderAdd(); + + /** + * Specifies if the peer certificate is sent to the external authorization service. + * + * @see ExtAuthz#getIncludePeerCertificate() + */ + public abstract boolean includePeerCertificate(); + + /** + * The gRPC status returned to the client when the authorization server returns an error or is + * unreachable. Defaults to {@code PERMISSION_DENIED}. + * + * @see io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz#getStatusOnError() + */ + public abstract Status statusOnError(); + + /** + * Specifies whether to deny requests when the filter is disabled. Defaults to {@code false}. + * + * @see ExtAuthz#getDenyAtDisable() + */ + public abstract boolean denyAtDisable(); + + /** + * The fraction of requests that will be checked by the authorization service. Defaults to all + * requests. + * + * @see ExtAuthz#getFilterEnabled() + */ + public abstract Matchers.FractionMatcher filterEnabled(); + + /** + * Specifies which request headers are sent to the authorization service. If not set, all headers + * are sent. + * + * @see ExtAuthz#getAllowedHeaders() + */ + public abstract ImmutableList allowedHeaders(); + + /** + * Specifies which request headers are not sent to the authorization service. This overrides + * {@link #allowedHeaders()}. + * + * @see ExtAuthz#getDisallowedHeaders() + */ + public abstract ImmutableList disallowedHeaders(); + + /** + * Rules for what modifications an ext_authz server may make to request headers. + * + * @see ExtAuthz#getDecoderHeaderMutationRules() + */ + public abstract Optional decoderHeaderMutationRules(); + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder grpcService(GrpcServiceConfig grpcService); + + public abstract Builder failureModeAllow(boolean failureModeAllow); + + public abstract Builder failureModeAllowHeaderAdd(boolean failureModeAllowHeaderAdd); + + public abstract Builder includePeerCertificate(boolean includePeerCertificate); + + public abstract Builder statusOnError(Status statusOnError); + + public abstract Builder denyAtDisable(boolean denyAtDisable); + + public abstract Builder filterEnabled(Matchers.FractionMatcher filterEnabled); + + public abstract Builder allowedHeaders(Iterable allowedHeaders); + + public abstract Builder disallowedHeaders(Iterable disallowedHeaders); + + public abstract Builder decoderHeaderMutationRules(HeaderMutationRulesConfig rules); + + public abstract ExtAuthzConfig build(); + } + + + private static Matchers.FractionMatcher parsePercent( + io.envoyproxy.envoy.type.v3.FractionalPercent proto) throws ExtAuthzParseException { + int denominator; + switch (proto.getDenominator()) { + case HUNDRED: + denominator = 100; + break; + case TEN_THOUSAND: + denominator = 10_000; + break; + case MILLION: + denominator = 1_000_000; + break; + case UNRECOGNIZED: + default: + throw new ExtAuthzParseException("Unknown denominator type: " + proto.getDenominator()); + } + return Matchers.FractionMatcher.create(proto.getNumerator(), denominator); + } + + private static HeaderMutationRulesConfig parseHeaderMutationRules(HeaderMutationRules proto) + throws ExtAuthzParseException { + HeaderMutationRulesConfig.Builder builder = HeaderMutationRulesConfig.builder(); + builder.disallowAll(proto.getDisallowAll().getValue()); + builder.disallowIsError(proto.getDisallowIsError().getValue()); + if (proto.hasAllowExpression()) { + builder.allowExpression( + parseRegex(proto.getAllowExpression().getRegex(), "allow_expression")); + } + if (proto.hasDisallowExpression()) { + builder.disallowExpression( + parseRegex(proto.getDisallowExpression().getRegex(), "disallow_expression")); + } + return builder.build(); + } + + private static Pattern parseRegex(String regex, String fieldName) throws ExtAuthzParseException { + try { + return Pattern.compile(regex); + } catch (PatternSyntaxException e) { + throw new ExtAuthzParseException( + "Invalid regex pattern for " + fieldName + ": " + e.getMessage(), e); + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzParseException.java b/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzParseException.java new file mode 100644 index 00000000000..78edea5c305 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzParseException.java @@ -0,0 +1,34 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.extauthz; + +/** + * A custom exception for signaling errors during the parsing of external authorization + * (ext_authz) configurations. + */ +public class ExtAuthzParseException extends Exception { + + private static final long serialVersionUID = 0L; + + public ExtAuthzParseException(String message) { + super(message); + } + + public ExtAuthzParseException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfig.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfig.java new file mode 100644 index 00000000000..da9be978f87 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfig.java @@ -0,0 +1,308 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.grpcservice; + +import com.google.auth.oauth2.AccessToken; +import com.google.auth.oauth2.OAuth2Credentials; +import com.google.auto.value.AutoValue; +import com.google.common.io.BaseEncoding; +import com.google.protobuf.Any; +import com.google.protobuf.InvalidProtocolBufferException; +import io.envoyproxy.envoy.config.core.v3.GrpcService; +import io.envoyproxy.envoy.extensions.grpc_service.call_credentials.access_token.v3.AccessTokenCredentials; +import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.xds.v3.XdsCredentials; +import io.grpc.CallCredentials; +import io.grpc.ChannelCredentials; +import io.grpc.InsecureChannelCredentials; +import io.grpc.Metadata; +import io.grpc.alts.GoogleDefaultChannelCredentials; +import io.grpc.auth.MoreCallCredentials; +import io.grpc.xds.XdsChannelCredentials; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Optional; + + +/** + * A Java representation of the {@link io.envoyproxy.envoy.config.core.v3.GrpcService} proto, + * designed for parsing and internal use within gRPC. This class encapsulates the configuration for + * a gRPC service, including target URI, credentials, and other settings. The parsing logic adheres + * to the specifications outlined in + * A102: xDS GrpcService Support. This class is immutable and uses the AutoValue library for its + * implementation. + */ +@AutoValue +public abstract class GrpcServiceConfig { + + public static Builder builder() { + return new AutoValue_GrpcServiceConfig.Builder(); + } + + /** + * Parses the {@link io.envoyproxy.envoy.config.core.v3.GrpcService} proto to create a + * {@link GrpcServiceConfig} instance. This method adheres to gRFC A102, which specifies that only + * the {@code google_grpc} target specifier is supported. Other fields like {@code timeout} and + * {@code initial_metadata} are also parsed as per the gRFC. + * + * @param grpcServiceProto The proto to parse. + * @return A {@link GrpcServiceConfig} instance. + * @throws GrpcServiceParseException if the proto is invalid or uses unsupported features. + */ + public static GrpcServiceConfig fromProto(GrpcService grpcServiceProto) + throws GrpcServiceParseException { + if (!grpcServiceProto.hasGoogleGrpc()) { + throw new GrpcServiceParseException( + "Unsupported: GrpcService must have GoogleGrpc, got: " + grpcServiceProto); + } + GoogleGrpcConfig googleGrpcConfig = + GoogleGrpcConfig.fromProto(grpcServiceProto.getGoogleGrpc()); + + Builder builder = GrpcServiceConfig.builder().googleGrpc(googleGrpcConfig); + + if (!grpcServiceProto.getInitialMetadataList().isEmpty()) { + Metadata initialMetadata = new Metadata(); + for (io.envoyproxy.envoy.config.core.v3.HeaderValue header : grpcServiceProto + .getInitialMetadataList()) { + String key = header.getKey(); + if (key.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + initialMetadata.put(Metadata.Key.of(key, Metadata.BINARY_BYTE_MARSHALLER), + BaseEncoding.base64().decode(header.getValue())); + } else { + initialMetadata.put(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER), + header.getValue()); + } + } + builder.initialMetadata(initialMetadata); + } + + if (grpcServiceProto.hasTimeout()) { + com.google.protobuf.Duration timeout = grpcServiceProto.getTimeout(); + builder.timeout(Duration.ofSeconds(timeout.getSeconds(), timeout.getNanos())); + } + return builder.build(); + } + + public abstract GoogleGrpcConfig googleGrpc(); + + public abstract Optional timeout(); + + public abstract Optional initialMetadata(); + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder googleGrpc(GoogleGrpcConfig googleGrpc); + + public abstract Builder timeout(Duration timeout); + + public abstract Builder initialMetadata(Metadata initialMetadata); + + public abstract GrpcServiceConfig build(); + } + + /** + * Represents the configuration for a Google gRPC service, as defined in the + * {@link io.envoyproxy.envoy.config.core.v3.GrpcService.GoogleGrpc} proto. This class + * encapsulates settings specific to Google's gRPC implementation, such as target URI and + * credentials. The parsing of this configuration is guided by gRFC A102, which specifies how gRPC + * clients should interpret the GrpcService proto. + */ + @AutoValue + public abstract static class GoogleGrpcConfig { + + private static final String TLS_CREDENTIALS_TYPE_URL = + "type.googleapis.com/envoy.extensions.grpc_service.channel_credentials." + + "tls.v3.TlsCredentials"; + private static final String LOCAL_CREDENTIALS_TYPE_URL = + "type.googleapis.com/envoy.extensions.grpc_service.channel_credentials." + + "local.v3.LocalCredentials"; + private static final String XDS_CREDENTIALS_TYPE_URL = + "type.googleapis.com/envoy.extensions.grpc_service.channel_credentials." + + "xds.v3.XdsCredentials"; + private static final String INSECURE_CREDENTIALS_TYPE_URL = + "type.googleapis.com/envoy.extensions.grpc_service.channel_credentials." + + "insecure.v3.InsecureCredentials"; + private static final String GOOGLE_DEFAULT_CREDENTIALS_TYPE_URL = + "type.googleapis.com/envoy.extensions.grpc_service.channel_credentials." + + "google_default.v3.GoogleDefaultCredentials"; + + public static Builder builder() { + return new AutoValue_GrpcServiceConfig_GoogleGrpcConfig.Builder(); + } + + /** + * Parses the {@link io.envoyproxy.envoy.config.core.v3.GrpcService.GoogleGrpc} proto to create + * a {@link GoogleGrpcConfig} instance. + * + * @param googleGrpcProto The proto to parse. + * @return A {@link GoogleGrpcConfig} instance. + * @throws GrpcServiceParseException if the proto is invalid. + */ + public static GoogleGrpcConfig fromProto(GrpcService.GoogleGrpc googleGrpcProto) + throws GrpcServiceParseException { + + HashedChannelCredentials channelCreds = + extractChannelCredentials(googleGrpcProto.getChannelCredentialsPluginList()); + + CallCredentials callCreds = + extractCallCredentials(googleGrpcProto.getCallCredentialsPluginList()); + + return GoogleGrpcConfig.builder().target(googleGrpcProto.getTargetUri()) + .hashedChannelCredentials(channelCreds).callCredentials(callCreds).build(); + } + + public abstract String target(); + + public abstract HashedChannelCredentials hashedChannelCredentials(); + + public abstract CallCredentials callCredentials(); + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder target(String target); + + public abstract Builder hashedChannelCredentials(HashedChannelCredentials channelCredentials); + + public abstract Builder callCredentials(CallCredentials callCredentials); + + public abstract GoogleGrpcConfig build(); + } + + private static T getFirstSupported(List configs, Parser parser, + String configName) throws GrpcServiceParseException { + List errors = new ArrayList<>(); + for (U config : configs) { + try { + return parser.parse(config); + } catch (GrpcServiceParseException e) { + errors.add(e.getMessage()); + } + } + throw new GrpcServiceParseException( + "No valid supported " + configName + " found. Errors: " + errors); + } + + private static HashedChannelCredentials channelCredsFromProto(Any cred) + throws GrpcServiceParseException { + String typeUrl = cred.getTypeUrl(); + try { + switch (typeUrl) { + case GOOGLE_DEFAULT_CREDENTIALS_TYPE_URL: + return HashedChannelCredentials.of(GoogleDefaultChannelCredentials.create(), + cred.hashCode()); + case INSECURE_CREDENTIALS_TYPE_URL: + return HashedChannelCredentials.of(InsecureChannelCredentials.create(), + cred.hashCode()); + case XDS_CREDENTIALS_TYPE_URL: + XdsCredentials xdsConfig = cred.unpack(XdsCredentials.class); + HashedChannelCredentials fallbackCreds = + channelCredsFromProto(xdsConfig.getFallbackCredentials()); + return HashedChannelCredentials.of( + XdsChannelCredentials.create(fallbackCreds.channelCredentials()), cred.hashCode()); + case LOCAL_CREDENTIALS_TYPE_URL: + // TODO(sauravzg) : What's the java alternative to LocalCredentials. + throw new GrpcServiceParseException("LocalCredentials are not yet supported."); + case TLS_CREDENTIALS_TYPE_URL: + // TODO(sauravzg) : How to instantiate a TlsChannelCredentials from TlsCredentials + // proto? + throw new GrpcServiceParseException("TlsCredentials are not yet supported."); + default: + throw new GrpcServiceParseException("Unsupported channel credentials type: " + typeUrl); + } + } catch (InvalidProtocolBufferException e) { + // TODO(sauravzg): Add unit tests when we have a solution for TLS creds. + // This code is as of writing unreachable because all channel credential message + // types except TLS are empty messages. + throw new GrpcServiceParseException( + "Failed to parse channel credentials: " + e.getMessage()); + } + } + + private static CallCredentials callCredsFromProto(Any cred) throws GrpcServiceParseException { + try { + AccessTokenCredentials accessToken = cred.unpack(AccessTokenCredentials.class); + // TODO(sauravzg): Verify if the current behavior is per spec.The `AccessTokenCredentials` + // config doesn't have any timeout/refresh, so set the token to never expire. + return MoreCallCredentials.from(OAuth2Credentials + .create(new AccessToken(accessToken.getToken(), new Date(Long.MAX_VALUE)))); + } catch (InvalidProtocolBufferException e) { + throw new GrpcServiceParseException( + "Unsupported call credentials type: " + cred.getTypeUrl()); + } + } + + private static HashedChannelCredentials extractChannelCredentials( + List channelCredentialPlugins) throws GrpcServiceParseException { + return getFirstSupported(channelCredentialPlugins, GoogleGrpcConfig::channelCredsFromProto, + "channel_credentials"); + } + + private static CallCredentials extractCallCredentials(List callCredentialPlugins) + throws GrpcServiceParseException { + return getFirstSupported(callCredentialPlugins, GoogleGrpcConfig::callCredsFromProto, + "call_credentials"); + } + } + + /** + * A container for {@link ChannelCredentials} and a hash for the purpose of caching. + */ + @AutoValue + public abstract static class HashedChannelCredentials { + /** + * Creates a new {@link HashedChannelCredentials} instance. + * + * @param creds The channel credentials. + * @param hash The hash of the credentials. + * @return A new {@link HashedChannelCredentials} instance. + */ + public static HashedChannelCredentials of(ChannelCredentials creds, int hash) { + return new AutoValue_GrpcServiceConfig_HashedChannelCredentials(creds, hash); + } + + /** + * Returns the channel credentials. + */ + public abstract ChannelCredentials channelCredentials(); + + /** + * Returns the hash of the credentials. + */ + public abstract int hash(); + } + + /** + * Defines a generic interface for parsing a configuration of type {@code U} into a result of type + * {@code T}. This functional interface is used to abstract the parsing logic for different parts + * of the GrpcService configuration. + * + * @param The type of the object that will be returned after parsing. + * @param The type of the configuration object that will be parsed. + */ + private interface Parser { + + /** + * Parses the given configuration. + * + * @param config The configuration object to parse. + * @return The parsed object of type {@code T}. + * @throws GrpcServiceParseException if an error occurs during parsing. + */ + T parse(U config) throws GrpcServiceParseException; + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigChannelFactory.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigChannelFactory.java new file mode 100644 index 00000000000..0d02989eaa3 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigChannelFactory.java @@ -0,0 +1,26 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.grpcservice; + +import io.grpc.ManagedChannel; + +/** + * A factory for creating {@link ManagedChannel}s from a {@link GrpcServiceConfig}. + */ +public interface GrpcServiceConfigChannelFactory { + ManagedChannel createChannel(GrpcServiceConfig config); +} diff --git a/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceParseException.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceParseException.java new file mode 100644 index 00000000000..319ad3d07e3 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceParseException.java @@ -0,0 +1,33 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.grpcservice; + +/** + * Exception thrown when there is an error parsing the gRPC service config. + */ +public class GrpcServiceParseException extends Exception { + + private static final long serialVersionUID = 1L; + + public GrpcServiceParseException(String message) { + super(message); + } + + public GrpcServiceParseException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/grpcservice/InsecureGrpcChannelFactory.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/InsecureGrpcChannelFactory.java new file mode 100644 index 00000000000..d6325d43be4 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/InsecureGrpcChannelFactory.java @@ -0,0 +1,43 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.grpcservice; + +import io.grpc.Grpc; +import io.grpc.ManagedChannel; + +/** + * An insecure implementation of {@link GrpcServiceConfigChannelFactory} that creates a plaintext + * channel. This is a stub implementation for channel creation until the GrpcService trusted server + * implementation is completely implemented. + */ +public final class InsecureGrpcChannelFactory implements GrpcServiceConfigChannelFactory { + + private static final InsecureGrpcChannelFactory INSTANCE = new InsecureGrpcChannelFactory(); + + private InsecureGrpcChannelFactory() {} + + public static InsecureGrpcChannelFactory getInstance() { + return INSTANCE; + } + + @Override + public ManagedChannel createChannel(GrpcServiceConfig config) { + GrpcServiceConfig.GoogleGrpcConfig googleGrpc = config.googleGrpc(); + return Grpc.newChannelBuilder(googleGrpc.target(), + googleGrpc.hashedChannelCredentials().channelCredentials()).build(); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfig.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfig.java new file mode 100644 index 00000000000..fd8048fdbd2 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfig.java @@ -0,0 +1,77 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.headermutations; + +import com.google.auto.value.AutoValue; +import io.envoyproxy.envoy.config.common.mutation_rules.v3.HeaderMutationRules; +import java.util.Optional; +import java.util.regex.Pattern; + +/** + * Represents the configuration for header mutation rules, as defined in the + * {@link io.envoyproxy.envoy.config.common.mutation_rules.v3.HeaderMutationRules} proto. + */ +@AutoValue +public abstract class HeaderMutationRulesConfig { + /** Creates a new builder for creating {@link HeaderMutationRulesConfig} instances. */ + public static Builder builder() { + return new AutoValue_HeaderMutationRulesConfig.Builder().disallowAll(false) + .disallowIsError(false); + } + + /** + * If set, allows any header that matches this regular expression. + * + * @see HeaderMutationRules#getAllowExpression() + */ + public abstract Optional allowExpression(); + + /** + * If set, disallows any header that matches this regular expression. + * + * @see HeaderMutationRules#getDisallowExpression() + */ + public abstract Optional disallowExpression(); + + /** + * If true, disallows all header mutations. + * + * @see HeaderMutationRules#getDisallowAll() + */ + public abstract boolean disallowAll(); + + /** + * If true, disallows any header mutation that would result in an invalid header value. + * + * @see HeaderMutationRules#getDisallowIsError() + */ + public abstract boolean disallowIsError(); + + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder allowExpression(Pattern matcher); + + public abstract Builder disallowExpression(Pattern matcher); + + public abstract Builder disallowAll(boolean disallowAll); + + public abstract Builder disallowIsError(boolean disallowIsError); + + public abstract HeaderMutationRulesConfig build(); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzConfigTest.java b/xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzConfigTest.java new file mode 100644 index 00000000000..9b9a55b4079 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzConfigTest.java @@ -0,0 +1,259 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.extauthz; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; + +import com.google.protobuf.Any; +import com.google.protobuf.BoolValue; +import io.envoyproxy.envoy.config.common.mutation_rules.v3.HeaderMutationRules; +import io.envoyproxy.envoy.config.core.v3.HeaderValue; +import io.envoyproxy.envoy.config.core.v3.RuntimeFeatureFlag; +import io.envoyproxy.envoy.config.core.v3.RuntimeFractionalPercent; +import io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz; +import io.envoyproxy.envoy.extensions.grpc_service.call_credentials.access_token.v3.AccessTokenCredentials; +import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.google_default.v3.GoogleDefaultCredentials; +import io.envoyproxy.envoy.type.matcher.v3.ListStringMatcher; +import io.envoyproxy.envoy.type.matcher.v3.RegexMatcher; +import io.envoyproxy.envoy.type.matcher.v3.StringMatcher; +import io.envoyproxy.envoy.type.v3.FractionalPercent; +import io.envoyproxy.envoy.type.v3.FractionalPercent.DenominatorType; +import io.grpc.Status; +import io.grpc.xds.internal.Matchers; +import io.grpc.xds.internal.headermutations.HeaderMutationRulesConfig; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class ExtAuthzConfigTest { + + private static final Any GOOGLE_DEFAULT_CHANNEL_CREDS = + Any.pack(GoogleDefaultCredentials.newBuilder().build()); + private static final Any FAKE_ACCESS_TOKEN_CALL_CREDS = + Any.pack(AccessTokenCredentials.newBuilder().build()); + + private ExtAuthz.Builder extAuthzBuilder; + + @Before + public void setUp() { + extAuthzBuilder = ExtAuthz.newBuilder() + .setGrpcService(io.envoyproxy.envoy.config.core.v3.GrpcService.newBuilder() + .setGoogleGrpc(io.envoyproxy.envoy.config.core.v3.GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("test-cluster") + .addChannelCredentialsPlugin(GOOGLE_DEFAULT_CHANNEL_CREDS) + .addCallCredentialsPlugin(FAKE_ACCESS_TOKEN_CALL_CREDS).build()) + .build()); + } + + @Test + public void fromProto_missingGrpcService_throws() { + ExtAuthz extAuthz = ExtAuthz.newBuilder().build(); + try { + ExtAuthzConfig.fromProto(extAuthz); + fail("Expected ExtAuthzParseException"); + } catch (ExtAuthzParseException e) { + assertThat(e).hasMessageThat() + .isEqualTo("unsupported ExtAuthz service type: only grpc_service is supported"); + } + } + + @Test + public void fromProto_invalidGrpcService_throws() { + ExtAuthz extAuthz = ExtAuthz.newBuilder() + .setGrpcService(io.envoyproxy.envoy.config.core.v3.GrpcService.newBuilder().build()) + .build(); + try { + ExtAuthzConfig.fromProto(extAuthz); + fail("Expected ExtAuthzParseException"); + } catch (ExtAuthzParseException e) { + assertThat(e).hasMessageThat().startsWith("Failed to parse GrpcService config:"); + } + } + + @Test + public void fromProto_invalidAllowExpression_throws() { + ExtAuthz extAuthz = extAuthzBuilder + .setDecoderHeaderMutationRules(HeaderMutationRules.newBuilder() + .setAllowExpression(RegexMatcher.newBuilder().setRegex("[invalid").build()).build()) + .build(); + try { + ExtAuthzConfig.fromProto(extAuthz); + fail("Expected ExtAuthzParseException"); + } catch (ExtAuthzParseException e) { + assertThat(e).hasMessageThat().startsWith("Invalid regex pattern for allow_expression:"); + } + } + + @Test + public void fromProto_invalidDisallowExpression_throws() { + ExtAuthz extAuthz = extAuthzBuilder + .setDecoderHeaderMutationRules(HeaderMutationRules.newBuilder() + .setDisallowExpression(RegexMatcher.newBuilder().setRegex("[invalid").build()).build()) + .build(); + try { + ExtAuthzConfig.fromProto(extAuthz); + fail("Expected ExtAuthzParseException"); + } catch (ExtAuthzParseException e) { + assertThat(e).hasMessageThat().startsWith("Invalid regex pattern for disallow_expression:"); + } + } + + @Test + public void fromProto_success() throws ExtAuthzParseException { + ExtAuthz extAuthz = extAuthzBuilder + .setGrpcService(extAuthzBuilder.getGrpcServiceBuilder() + .setTimeout(com.google.protobuf.Duration.newBuilder().setSeconds(5).build()) + .addInitialMetadata(HeaderValue.newBuilder().setKey("key").setValue("value").build()) + .build()) + .setFailureModeAllow(true).setFailureModeAllowHeaderAdd(true) + .setIncludePeerCertificate(true) + .setStatusOnError( + io.envoyproxy.envoy.type.v3.HttpStatus.newBuilder().setCodeValue(403).build()) + .setDenyAtDisable( + RuntimeFeatureFlag.newBuilder().setDefaultValue(BoolValue.of(true)).build()) + .setFilterEnabled(RuntimeFractionalPercent.newBuilder() + .setDefaultValue(FractionalPercent.newBuilder().setNumerator(50) + .setDenominator(DenominatorType.TEN_THOUSAND).build()) + .build()) + .setAllowedHeaders(ListStringMatcher.newBuilder() + .addPatterns(StringMatcher.newBuilder().setExact("allowed-header").build()).build()) + .setDisallowedHeaders(ListStringMatcher.newBuilder() + .addPatterns(StringMatcher.newBuilder().setPrefix("disallowed-").build()).build()) + .setDecoderHeaderMutationRules(HeaderMutationRules.newBuilder() + .setAllowExpression(RegexMatcher.newBuilder().setRegex("allow.*").build()) + .setDisallowExpression(RegexMatcher.newBuilder().setRegex("disallow.*").build()) + .setDisallowAll(BoolValue.of(true)).setDisallowIsError(BoolValue.of(true)).build()) + .build(); + + ExtAuthzConfig config = ExtAuthzConfig.fromProto(extAuthz); + + assertThat(config.grpcService().googleGrpc().target()).isEqualTo("test-cluster"); + assertThat(config.grpcService().timeout().get().getSeconds()).isEqualTo(5); + assertThat(config.grpcService().initialMetadata().isPresent()).isTrue(); + assertThat(config.failureModeAllow()).isTrue(); + assertThat(config.failureModeAllowHeaderAdd()).isTrue(); + assertThat(config.includePeerCertificate()).isTrue(); + assertThat(config.statusOnError().getCode()).isEqualTo(Status.PERMISSION_DENIED.getCode()); + assertThat(config.statusOnError().getDescription()).isEqualTo("HTTP status code 403"); + assertThat(config.denyAtDisable()).isTrue(); + assertThat(config.filterEnabled()).isEqualTo(Matchers.FractionMatcher.create(50, 10_000)); + assertThat(config.allowedHeaders()).hasSize(1); + assertThat(config.allowedHeaders().get(0).matches("allowed-header")).isTrue(); + assertThat(config.disallowedHeaders()).hasSize(1); + assertThat(config.disallowedHeaders().get(0).matches("disallowed-foo")).isTrue(); + assertThat(config.decoderHeaderMutationRules().isPresent()).isTrue(); + HeaderMutationRulesConfig rules = config.decoderHeaderMutationRules().get(); + assertThat(rules.allowExpression().get().pattern()).isEqualTo("allow.*"); + assertThat(rules.disallowExpression().get().pattern()).isEqualTo("disallow.*"); + assertThat(rules.disallowAll()).isTrue(); + assertThat(rules.disallowIsError()).isTrue(); + } + + @Test + public void fromProto_saneDefaults() throws ExtAuthzParseException { + ExtAuthz extAuthz = extAuthzBuilder.build(); + + ExtAuthzConfig config = ExtAuthzConfig.fromProto(extAuthz); + + assertThat(config.failureModeAllow()).isFalse(); + assertThat(config.failureModeAllowHeaderAdd()).isFalse(); + assertThat(config.includePeerCertificate()).isFalse(); + assertThat(config.statusOnError()).isEqualTo(Status.PERMISSION_DENIED); + assertThat(config.denyAtDisable()).isFalse(); + assertThat(config.filterEnabled()).isEqualTo(Matchers.FractionMatcher.create(100, 100)); + assertThat(config.allowedHeaders()).isEmpty(); + assertThat(config.disallowedHeaders()).isEmpty(); + assertThat(config.decoderHeaderMutationRules().isPresent()).isFalse(); + } + + @Test + public void fromProto_headerMutationRules_allowExpressionOnly() throws ExtAuthzParseException { + ExtAuthz extAuthz = extAuthzBuilder + .setDecoderHeaderMutationRules(HeaderMutationRules.newBuilder() + .setAllowExpression(RegexMatcher.newBuilder().setRegex("allow.*").build()).build()) + .build(); + + ExtAuthzConfig config = ExtAuthzConfig.fromProto(extAuthz); + + assertThat(config.decoderHeaderMutationRules().isPresent()).isTrue(); + HeaderMutationRulesConfig rules = config.decoderHeaderMutationRules().get(); + assertThat(rules.allowExpression().get().pattern()).isEqualTo("allow.*"); + assertThat(rules.disallowExpression().isPresent()).isFalse(); + } + + @Test + public void fromProto_headerMutationRules_disallowExpressionOnly() throws ExtAuthzParseException { + ExtAuthz extAuthz = extAuthzBuilder + .setDecoderHeaderMutationRules(HeaderMutationRules.newBuilder() + .setDisallowExpression(RegexMatcher.newBuilder().setRegex("disallow.*").build()) + .build()) + .build(); + + ExtAuthzConfig config = ExtAuthzConfig.fromProto(extAuthz); + + assertThat(config.decoderHeaderMutationRules().isPresent()).isTrue(); + HeaderMutationRulesConfig rules = config.decoderHeaderMutationRules().get(); + assertThat(rules.allowExpression().isPresent()).isFalse(); + assertThat(rules.disallowExpression().get().pattern()).isEqualTo("disallow.*"); + } + + @Test + public void fromProto_filterEnabled_hundred() throws ExtAuthzParseException { + ExtAuthz extAuthz = extAuthzBuilder + .setFilterEnabled(RuntimeFractionalPercent.newBuilder().setDefaultValue(FractionalPercent + .newBuilder().setNumerator(25).setDenominator(DenominatorType.HUNDRED).build()).build()) + .build(); + + ExtAuthzConfig config = ExtAuthzConfig.fromProto(extAuthz); + + assertThat(config.filterEnabled()).isEqualTo(Matchers.FractionMatcher.create(25, 100)); + } + + @Test + public void fromProto_filterEnabled_million() throws ExtAuthzParseException { + ExtAuthz extAuthz = extAuthzBuilder + .setFilterEnabled( + RuntimeFractionalPercent.newBuilder().setDefaultValue(FractionalPercent.newBuilder() + .setNumerator(123456).setDenominator(DenominatorType.MILLION).build()).build()) + .build(); + + ExtAuthzConfig config = ExtAuthzConfig.fromProto(extAuthz); + + assertThat(config.filterEnabled()) + .isEqualTo(Matchers.FractionMatcher.create(123456, 1_000_000)); + } + + @Test + public void fromProto_filterEnabled_unrecognizedDenominator() { + ExtAuthz extAuthz = extAuthzBuilder + .setFilterEnabled(RuntimeFractionalPercent.newBuilder() + .setDefaultValue( + FractionalPercent.newBuilder().setNumerator(1).setDenominatorValue(4).build()) + .build()) + .build(); + + try { + ExtAuthzConfig.fromProto(extAuthz); + fail("Expected ExtAuthzParseException"); + } catch (ExtAuthzParseException e) { + assertThat(e).hasMessageThat().isEqualTo("Unknown denominator type: UNRECOGNIZED"); + } + } +} \ No newline at end of file diff --git a/xds/src/test/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigTest.java b/xds/src/test/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigTest.java new file mode 100644 index 00000000000..7a506220973 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigTest.java @@ -0,0 +1,243 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.grpcservice; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.common.io.BaseEncoding; +import com.google.protobuf.Any; +import com.google.protobuf.Duration; +import io.envoyproxy.envoy.config.core.v3.GrpcService; +import io.envoyproxy.envoy.config.core.v3.HeaderValue; +import io.envoyproxy.envoy.extensions.grpc_service.call_credentials.access_token.v3.AccessTokenCredentials; +import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.google_default.v3.GoogleDefaultCredentials; +import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.insecure.v3.InsecureCredentials; +import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.local.v3.LocalCredentials; +import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.xds.v3.XdsCredentials; +import io.grpc.InsecureChannelCredentials; +import io.grpc.Metadata; +import java.nio.charset.StandardCharsets; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class GrpcServiceConfigTest { + + @Test + public void fromProto_success() throws GrpcServiceParseException { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).addCallCredentialsPlugin(accessTokenCreds) + .build(); + HeaderValue asciiHeader = + HeaderValue.newBuilder().setKey("test_key").setValue("test_value").build(); + HeaderValue binaryHeader = HeaderValue.newBuilder().setKey("test_key-bin") + .setValue( + BaseEncoding.base64().encode("test_value_binary".getBytes(StandardCharsets.UTF_8))) + .build(); + Duration timeout = Duration.newBuilder().setSeconds(10).build(); + GrpcService grpcService = + GrpcService.newBuilder().setGoogleGrpc(googleGrpc).addInitialMetadata(asciiHeader) + .addInitialMetadata(binaryHeader).setTimeout(timeout).build(); + + GrpcServiceConfig config = GrpcServiceConfig.fromProto(grpcService); + + // Assert target URI + assertThat(config.googleGrpc().target()).isEqualTo("test_uri"); + + // Assert channel credentials + assertThat(config.googleGrpc().hashedChannelCredentials().channelCredentials()) + .isInstanceOf(InsecureChannelCredentials.class); + assertThat(config.googleGrpc().hashedChannelCredentials().hash()) + .isEqualTo(insecureCreds.hashCode()); + + // Assert call credentials + assertThat(config.googleGrpc().callCredentials().getClass().getName()) + .isEqualTo("io.grpc.auth.GoogleAuthLibraryCallCredentials"); + + // Assert initial metadata + assertThat(config.initialMetadata().isPresent()).isTrue(); + assertThat(config.initialMetadata().get() + .get(Metadata.Key.of("test_key", Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("test_value"); + assertThat(config.initialMetadata().get() + .get(Metadata.Key.of("test_key-bin", Metadata.BINARY_BYTE_MARSHALLER))) + .isEqualTo("test_value_binary".getBytes(StandardCharsets.UTF_8)); + + // Assert timeout + assertThat(config.timeout().isPresent()).isTrue(); + assertThat(config.timeout().get()).isEqualTo(java.time.Duration.ofSeconds(10)); + } + + @Test + public void fromProto_minimalSuccess_defaults() throws GrpcServiceParseException { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).addCallCredentialsPlugin(accessTokenCreds) + .build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceConfig config = GrpcServiceConfig.fromProto(grpcService); + + assertThat(config.googleGrpc().target()).isEqualTo("test_uri"); + assertThat(config.initialMetadata().isPresent()).isFalse(); + assertThat(config.timeout().isPresent()).isFalse(); + } + + @Test + public void fromProto_missingGoogleGrpc() { + GrpcService grpcService = GrpcService.newBuilder().build(); + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> GrpcServiceConfig.fromProto(grpcService)); + assertThat(exception).hasMessageThat() + .startsWith("Unsupported: GrpcService must have GoogleGrpc, got: "); + } + + @Test + public void fromProto_emptyCallCredentials() { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> GrpcServiceConfig.fromProto(grpcService)); + assertThat(exception).hasMessageThat() + .isEqualTo("No valid supported call_credentials found. Errors: []"); + } + + @Test + public void fromProto_emptyChannelCredentials() { + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addCallCredentialsPlugin(accessTokenCreds).build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> GrpcServiceConfig.fromProto(grpcService)); + assertThat(exception).hasMessageThat() + .isEqualTo("No valid supported channel_credentials found. Errors: []"); + } + + @Test + public void fromProto_googleDefaultCredentials() throws GrpcServiceParseException { + Any googleDefaultCreds = Any.pack(GoogleDefaultCredentials.getDefaultInstance()); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(googleDefaultCreds).addCallCredentialsPlugin(accessTokenCreds) + .build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceConfig config = GrpcServiceConfig.fromProto(grpcService); + + assertThat(config.googleGrpc().hashedChannelCredentials().channelCredentials()) + .isInstanceOf(io.grpc.CompositeChannelCredentials.class); + assertThat(config.googleGrpc().hashedChannelCredentials().hash()) + .isEqualTo(googleDefaultCreds.hashCode()); + } + + @Test + public void fromProto_localCredentials() throws GrpcServiceParseException { + Any localCreds = Any.pack(LocalCredentials.getDefaultInstance()); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(localCreds).addCallCredentialsPlugin(accessTokenCreds).build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> GrpcServiceConfig.fromProto(grpcService)); + assertThat(exception).hasMessageThat().contains("LocalCredentials are not yet supported."); + } + + @Test + public void fromProto_xdsCredentials_withInsecureFallback() throws GrpcServiceParseException { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + XdsCredentials xdsCreds = + XdsCredentials.newBuilder().setFallbackCredentials(insecureCreds).build(); + Any xdsCredsAny = Any.pack(xdsCreds); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(xdsCredsAny).addCallCredentialsPlugin(accessTokenCreds) + .build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceConfig config = GrpcServiceConfig.fromProto(grpcService); + + assertThat(config.googleGrpc().hashedChannelCredentials().channelCredentials()) + .isInstanceOf(io.grpc.ChannelCredentials.class); + assertThat(config.googleGrpc().hashedChannelCredentials().hash()) + .isEqualTo(xdsCredsAny.hashCode()); + } + + @Test + public void fromProto_tlsCredentials_notSupported() { + Any tlsCreds = Any + .pack(io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.tls.v3.TlsCredentials + .getDefaultInstance()); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(tlsCreds).addCallCredentialsPlugin(accessTokenCreds).build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> GrpcServiceConfig.fromProto(grpcService)); + assertThat(exception).hasMessageThat().contains("TlsCredentials are not yet supported."); + } + + @Test + public void fromProto_invalidChannelCredentialsProto() { + // Pack a Duration proto, but try to unpack it as GoogleDefaultCredentials + Any invalidCreds = Any.pack(com.google.protobuf.Duration.getDefaultInstance()); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(invalidCreds).addCallCredentialsPlugin(accessTokenCreds) + .build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> GrpcServiceConfig.fromProto(grpcService)); + assertThat(exception).hasMessageThat() + .contains("No valid supported channel_credentials found. Errors: [Unsupported channel " + + "credentials type: type.googleapis.com/google.protobuf.Duration"); + } + + @Test + public void fromProto_invalidCallCredentialsProto() { + // Pack a Duration proto, but try to unpack it as AccessTokenCredentials + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + Any invalidCallCredentials = Any.pack(Duration.getDefaultInstance()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).addCallCredentialsPlugin(invalidCallCredentials) + .build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> GrpcServiceConfig.fromProto(grpcService)); + assertThat(exception).hasMessageThat().contains("Unsupported call credentials type:"); + } +} + diff --git a/xds/src/test/java/io/grpc/xds/internal/grpcservice/InsecureGrpcChannelFactoryTest.java b/xds/src/test/java/io/grpc/xds/internal/grpcservice/InsecureGrpcChannelFactoryTest.java new file mode 100644 index 00000000000..8d7347f56c6 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/grpcservice/InsecureGrpcChannelFactoryTest.java @@ -0,0 +1,57 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.grpcservice; + +import static org.junit.Assert.assertNotNull; + +import io.grpc.CallCredentials; +import io.grpc.InsecureChannelCredentials; +import io.grpc.ManagedChannel; +import io.grpc.Metadata; +import io.grpc.xds.internal.grpcservice.GrpcServiceConfig.GoogleGrpcConfig; +import io.grpc.xds.internal.grpcservice.GrpcServiceConfig.HashedChannelCredentials; +import java.util.concurrent.Executor; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for {@link InsecureGrpcChannelFactory}. */ +@RunWith(JUnit4.class) +public class InsecureGrpcChannelFactoryTest { + + private static final class NoOpCallCredentials extends CallCredentials { + @Override + public void applyRequestMetadata(RequestInfo requestInfo, Executor appExecutor, + MetadataApplier applier) { + applier.apply(new Metadata()); + } + } + + @Test + public void testCreateChannel() { + InsecureGrpcChannelFactory factory = InsecureGrpcChannelFactory.getInstance(); + GrpcServiceConfig config = GrpcServiceConfig.builder() + .googleGrpc(GoogleGrpcConfig.builder().target("localhost:8080") + .hashedChannelCredentials( + HashedChannelCredentials.of(InsecureChannelCredentials.create(), 0)) + .callCredentials(new NoOpCallCredentials()).build()) + .build(); + ManagedChannel channel = factory.createChannel(config); + assertNotNull(channel); + channel.shutdownNow(); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfigTest.java b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfigTest.java new file mode 100644 index 00000000000..e2bda9cb836 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfigTest.java @@ -0,0 +1,84 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.headermutations; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.regex.Pattern; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class HeaderMutationRulesConfigTest { + @Test + public void testBuilderDefaultValues() { + HeaderMutationRulesConfig config = HeaderMutationRulesConfig.builder().build(); + assertFalse(config.disallowAll()); + assertFalse(config.disallowIsError()); + assertThat(config.allowExpression()).isEmpty(); + assertThat(config.disallowExpression()).isEmpty(); + } + + @Test + public void testBuilder_setDisallowAll() { + HeaderMutationRulesConfig config = + HeaderMutationRulesConfig.builder().disallowAll(true).build(); + assertTrue(config.disallowAll()); + } + + @Test + public void testBuilder_setDisallowIsError() { + HeaderMutationRulesConfig config = + HeaderMutationRulesConfig.builder().disallowIsError(true).build(); + assertTrue(config.disallowIsError()); + } + + @Test + public void testBuilder_setAllowExpression() { + Pattern pattern = Pattern.compile("allow.*"); + HeaderMutationRulesConfig config = + HeaderMutationRulesConfig.builder().allowExpression(pattern).build(); + assertThat(config.allowExpression()).hasValue(pattern); + } + + @Test + public void testBuilder_setDisallowExpression() { + Pattern pattern = Pattern.compile("disallow.*"); + HeaderMutationRulesConfig config = + HeaderMutationRulesConfig.builder().disallowExpression(pattern).build(); + assertThat(config.disallowExpression()).hasValue(pattern); + } + + @Test + public void testBuilder_setAll() { + Pattern allowPattern = Pattern.compile("allow.*"); + Pattern disallowPattern = Pattern.compile("disallow.*"); + HeaderMutationRulesConfig config = HeaderMutationRulesConfig.builder() + .disallowAll(true) + .disallowIsError(true) + .allowExpression(allowPattern) + .disallowExpression(disallowPattern) + .build(); + assertTrue(config.disallowAll()); + assertTrue(config.disallowIsError()); + assertThat(config.allowExpression()).hasValue(allowPattern); + assertThat(config.disallowExpression()).hasValue(disallowPattern); + } +} From 5654c6496ccf0c731e88c4367d7c19cce76b8454 Mon Sep 17 00:00:00 2001 From: Saurav Date: Tue, 13 Jan 2026 04:11:34 +0000 Subject: [PATCH 2/6] Fixup: Address comments from #12492 --- .../io/grpc/xds/GrpcBootstrapperImpl.java | 105 ++++- .../java/io/grpc/xds/client/Bootstrapper.java | 10 + .../io/grpc/xds/client/BootstrapperImpl.java | 11 + .../io/grpc/xds/internal/MatcherParser.java | 21 + .../grpc/xds/internal/XdsHeaderValidator.java | 40 ++ .../xds/internal/extauthz/ExtAuthzConfig.java | 109 +---- .../extauthz/ExtAuthzConfigParser.java | 96 +++++ ...elFactory.java => ChannelCredsConfig.java} | 11 +- .../ConfiguredChannelCredentials.java | 35 ++ .../grpcservice/GrpcServiceConfig.java | 244 +---------- .../grpcservice/GrpcServiceConfigParser.java | 323 +++++++++++++++ .../grpcservice/GrpcServiceXdsContext.java | 71 ++++ .../GrpcServiceXdsContextProvider.java | 31 ++ .../xds/internal/grpcservice/HeaderValue.java | 44 ++ .../InsecureGrpcChannelFactory.java | 43 -- .../HeaderMutationRulesConfig.java | 2 +- .../HeaderMutationRulesParser.java | 55 +++ .../io/grpc/xds/GrpcBootstrapperImplTest.java | 55 +++ .../grpc/xds/internal/MatcherParserTest.java | 85 ++++ .../xds/internal/XdsHeaderValidatorTest.java | 64 +++ ...est.java => ExtAuthzConfigParserTest.java} | 130 +++--- .../GrpcServiceConfigParserTest.java | 390 ++++++++++++++++++ .../grpcservice/GrpcServiceConfigTest.java | 243 ----------- .../GrpcServiceXdsContextTestUtil.java | 30 ++ .../internal/grpcservice/HeaderValueTest.java | 49 +++ .../InsecureGrpcChannelFactoryTest.java | 57 --- .../HeaderMutationRulesConfigTest.java | 2 +- .../HeaderMutationRulesParserTest.java | 90 ++++ 28 files changed, 1688 insertions(+), 758 deletions(-) create mode 100644 xds/src/main/java/io/grpc/xds/internal/XdsHeaderValidator.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzConfigParser.java rename xds/src/main/java/io/grpc/xds/internal/grpcservice/{GrpcServiceConfigChannelFactory.java => ChannelCredsConfig.java} (74%) create mode 100644 xds/src/main/java/io/grpc/xds/internal/grpcservice/ConfiguredChannelCredentials.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigParser.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceXdsContext.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceXdsContextProvider.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/grpcservice/HeaderValue.java delete mode 100644 xds/src/main/java/io/grpc/xds/internal/grpcservice/InsecureGrpcChannelFactory.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesParser.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/MatcherParserTest.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/XdsHeaderValidatorTest.java rename xds/src/test/java/io/grpc/xds/internal/extauthz/{ExtAuthzConfigTest.java => ExtAuthzConfigParserTest.java} (63%) create mode 100644 xds/src/test/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigParserTest.java delete mode 100644 xds/src/test/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigTest.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/grpcservice/GrpcServiceXdsContextTestUtil.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/grpcservice/HeaderValueTest.java delete mode 100644 xds/src/test/java/io/grpc/xds/internal/grpcservice/InsecureGrpcChannelFactoryTest.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesParserTest.java diff --git a/xds/src/main/java/io/grpc/xds/GrpcBootstrapperImpl.java b/xds/src/main/java/io/grpc/xds/GrpcBootstrapperImpl.java index 494e95a58f6..9420a87191d 100644 --- a/xds/src/main/java/io/grpc/xds/GrpcBootstrapperImpl.java +++ b/xds/src/main/java/io/grpc/xds/GrpcBootstrapperImpl.java @@ -19,14 +19,19 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableMap; import com.google.errorprone.annotations.concurrent.GuardedBy; +import io.grpc.CallCredentials; import io.grpc.ChannelCredentials; import io.grpc.internal.JsonUtil; import io.grpc.xds.client.BootstrapperImpl; import io.grpc.xds.client.XdsInitializationException; import io.grpc.xds.client.XdsLogger; +import io.grpc.xds.internal.grpcservice.ChannelCredsConfig; +import io.grpc.xds.internal.grpcservice.ConfiguredChannelCredentials; +import io.grpc.xds.internal.grpcservice.GrpcServiceXdsContext; import java.io.IOException; import java.util.List; import java.util.Map; +import java.util.Optional; import javax.annotation.Nullable; class GrpcBootstrapperImpl extends BootstrapperImpl { @@ -97,7 +102,8 @@ protected String getJsonContent() throws XdsInitializationException, IOException @Override protected Object getImplSpecificConfig(Map serverConfig, String serverUri) throws XdsInitializationException { - return getChannelCredentials(serverConfig, serverUri); + ConfiguredChannelCredentials configuredChannel = getChannelCredentials(serverConfig, serverUri); + return configuredChannel != null ? configuredChannel.channelCredentials() : null; } @GuardedBy("GrpcBootstrapperImpl.class") @@ -120,26 +126,26 @@ static synchronized BootstrapInfo defaultBootstrap() throws XdsInitializationExc return defaultBootstrap; } - private static ChannelCredentials getChannelCredentials(Map serverConfig, - String serverUri) + private static ConfiguredChannelCredentials getChannelCredentials(Map serverConfig, + String serverUri) throws XdsInitializationException { List rawChannelCredsList = JsonUtil.getList(serverConfig, "channel_creds"); if (rawChannelCredsList == null || rawChannelCredsList.isEmpty()) { throw new XdsInitializationException( "Invalid bootstrap: server " + serverUri + " 'channel_creds' required"); } - ChannelCredentials channelCredentials = + ConfiguredChannelCredentials credentials = parseChannelCredentials(JsonUtil.checkObjectList(rawChannelCredsList), serverUri); - if (channelCredentials == null) { + if (credentials == null) { throw new XdsInitializationException( "Server " + serverUri + ": no supported channel credentials found"); } - return channelCredentials; + return credentials; } @Nullable - private static ChannelCredentials parseChannelCredentials(List> jsonList, - String serverUri) + private static ConfiguredChannelCredentials parseChannelCredentials(List> jsonList, + String serverUri) throws XdsInitializationException { for (Map channelCreds : jsonList) { String type = JsonUtil.getString(channelCreds, "type"); @@ -155,9 +161,90 @@ private static ChannelCredentials parseChannelCredentials(List> j config = ImmutableMap.of(); } - return provider.newChannelCredentials(config); + ChannelCredentials creds = provider.newChannelCredentials(config); + if (creds == null) { + continue; + } + return ConfiguredChannelCredentials.create(creds, new JsonChannelCredsConfig(type, config)); } } return null; } + + @Override + protected Optional parseAllowedGrpcServices( + Map rawAllowedGrpcServices) + throws XdsInitializationException { + ImmutableMap.Builder builder = + ImmutableMap.builder(); + for (String targetUri : rawAllowedGrpcServices.keySet()) { + Map serviceConfig = JsonUtil.getObject(rawAllowedGrpcServices, targetUri); + if (serviceConfig == null) { + throw new XdsInitializationException( + "Invalid allowed_grpc_services config for " + targetUri); + } + ConfiguredChannelCredentials configuredChannel = + getChannelCredentials(serviceConfig, targetUri); + + Optional callCredentials = Optional.empty(); + List rawCallCredsList = JsonUtil.getList(serviceConfig, "call_creds"); + if (rawCallCredsList != null && !rawCallCredsList.isEmpty()) { + callCredentials = + parseCallCredentials(JsonUtil.checkObjectList(rawCallCredsList), targetUri); + } + + GrpcServiceXdsContext.AllowedGrpcService.Builder b = GrpcServiceXdsContext.AllowedGrpcService + .builder().configuredChannelCredentials(configuredChannel); + callCredentials.ifPresent(b::callCredentials); + builder.put(targetUri, b.build()); + } + ImmutableMap parsed = builder.buildOrThrow(); + return parsed.isEmpty() ? Optional.empty() : Optional.of(parsed); + } + + @SuppressWarnings("unused") + private static Optional parseCallCredentials(List> jsonList, + String targetUri) + throws XdsInitializationException { + // TODO(sauravzg): Currently no xDS call credentials providers are implemented (no + // XdsCallCredentialsRegistry). + // As per A102/A97, we should just ignore unsupported call credentials types + // without throwing an exception. + return Optional.empty(); + } + + private static final class JsonChannelCredsConfig implements ChannelCredsConfig { + private final String type; + private final Map config; + + JsonChannelCredsConfig(String type, Map config) { + this.type = type; + this.config = config; + } + + @Override + public String type() { + return type; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + JsonChannelCredsConfig that = (JsonChannelCredsConfig) o; + return java.util.Objects.equals(type, that.type) + && java.util.Objects.equals(config, that.config); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(type, config); + } + } + } + diff --git a/xds/src/main/java/io/grpc/xds/client/Bootstrapper.java b/xds/src/main/java/io/grpc/xds/client/Bootstrapper.java index 1d526703299..32f4216d0cd 100644 --- a/xds/src/main/java/io/grpc/xds/client/Bootstrapper.java +++ b/xds/src/main/java/io/grpc/xds/client/Bootstrapper.java @@ -26,6 +26,7 @@ import io.grpc.xds.client.EnvoyProtoData.Node; import java.util.List; import java.util.Map; +import java.util.Optional; import javax.annotation.Nullable; /** @@ -205,6 +206,12 @@ public abstract static class BootstrapInfo { */ public abstract ImmutableMap authorities(); + /** + * Parsed allowed_grpc_services configuration. + * Returns an opaque object containing the parsed configuration. + */ + public abstract Optional allowedGrpcServices(); + @VisibleForTesting public static Builder builder() { return new AutoValue_Bootstrapper_BootstrapInfo.Builder() @@ -231,7 +238,10 @@ public abstract Builder clientDefaultListenerResourceNameTemplate( public abstract Builder authorities(Map authorities); + public abstract Builder allowedGrpcServices(Optional allowedGrpcServices); + public abstract BootstrapInfo build(); } } + } diff --git a/xds/src/main/java/io/grpc/xds/client/BootstrapperImpl.java b/xds/src/main/java/io/grpc/xds/client/BootstrapperImpl.java index b44e32bb2d9..e267a9cb985 100644 --- a/xds/src/main/java/io/grpc/xds/client/BootstrapperImpl.java +++ b/xds/src/main/java/io/grpc/xds/client/BootstrapperImpl.java @@ -239,9 +239,20 @@ protected BootstrapInfo.Builder bootstrapBuilder(Map rawData) builder.authorities(authorityInfoMapBuilder.buildOrThrow()); } + Map rawAllowedGrpcServices = JsonUtil.getObject(rawData, "allowed_grpc_services"); + if (rawAllowedGrpcServices != null) { + builder.allowedGrpcServices(parseAllowedGrpcServices(rawAllowedGrpcServices)); + } + return builder; } + protected java.util.Optional parseAllowedGrpcServices( + Map rawAllowedGrpcServices) + throws XdsInitializationException { + return java.util.Optional.empty(); + } + private List parseServerInfos(List rawServerConfigs, XdsLogger logger) throws XdsInitializationException { logger.log(XdsLogLevel.INFO, "Configured with {0} xDS servers", rawServerConfigs.size()); diff --git a/xds/src/main/java/io/grpc/xds/internal/MatcherParser.java b/xds/src/main/java/io/grpc/xds/internal/MatcherParser.java index fb291efc461..91b77b05d01 100644 --- a/xds/src/main/java/io/grpc/xds/internal/MatcherParser.java +++ b/xds/src/main/java/io/grpc/xds/internal/MatcherParser.java @@ -97,4 +97,25 @@ public static Matchers.StringMatcher parseStringMatcher( "Unknown StringMatcher match pattern: " + proto.getMatchPatternCase()); } } + + /** Translates envoy proto FractionalPercent to internal FractionMatcher. */ + public static Matchers.FractionMatcher parseFractionMatcher( + io.envoyproxy.envoy.type.v3.FractionalPercent proto) { + int denominator; + switch (proto.getDenominator()) { + case HUNDRED: + denominator = 100; + break; + case TEN_THOUSAND: + denominator = 10_000; + break; + case MILLION: + denominator = 1_000_000; + break; + case UNRECOGNIZED: + default: + throw new IllegalArgumentException("Unknown denominator type: " + proto.getDenominator()); + } + return Matchers.FractionMatcher.create(proto.getNumerator(), denominator); + } } diff --git a/xds/src/main/java/io/grpc/xds/internal/XdsHeaderValidator.java b/xds/src/main/java/io/grpc/xds/internal/XdsHeaderValidator.java new file mode 100644 index 00000000000..dbd459b017b --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/XdsHeaderValidator.java @@ -0,0 +1,40 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal; + +/** + * Utility for validating header keys and values against xDS and Envoy specifications. + */ +public final class XdsHeaderValidator { + + private XdsHeaderValidator() {} + + /** + * Returns whether the header parameter is valid. The length to check is either the + * length of the string value or the size of the binary raw value. + */ + public static boolean isValid(String key, int valueLength) { + if (key.isEmpty() || !key.equals(key.toLowerCase(java.util.Locale.ROOT)) || key.length() > 16384 + || key.equals("host") || key.startsWith(":")) { + return false; + } + if (valueLength > 16384) { + return false; + } + return true; + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzConfig.java b/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzConfig.java index e826f501d9c..fec8e605d73 100644 --- a/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzConfig.java +++ b/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzConfig.java @@ -18,18 +18,11 @@ import com.google.auto.value.AutoValue; import com.google.common.collect.ImmutableList; -import io.envoyproxy.envoy.config.common.mutation_rules.v3.HeaderMutationRules; -import io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz; import io.grpc.Status; -import io.grpc.internal.GrpcUtil; -import io.grpc.xds.internal.MatcherParser; import io.grpc.xds.internal.Matchers; import io.grpc.xds.internal.grpcservice.GrpcServiceConfig; -import io.grpc.xds.internal.grpcservice.GrpcServiceParseException; import io.grpc.xds.internal.headermutations.HeaderMutationRulesConfig; import java.util.Optional; -import java.util.regex.Pattern; -import java.util.regex.PatternSyntaxException; /** * Represents the configuration for the external authorization (ext_authz) filter. This class @@ -42,64 +35,12 @@ public abstract class ExtAuthzConfig { /** Creates a new builder for creating {@link ExtAuthzConfig} instances. */ - public static Builder builder() { + public static Builder newBuilder() { return new AutoValue_ExtAuthzConfig.Builder().allowedHeaders(ImmutableList.of()) .disallowedHeaders(ImmutableList.of()).statusOnError(Status.PERMISSION_DENIED) .filterEnabled(Matchers.FractionMatcher.create(100, 100)); } - /** - * Parses the {@link io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz} proto to - * create an {@link ExtAuthzConfig} instance. - * - * @param extAuthzProto The ext_authz proto to parse. - * @return An {@link ExtAuthzConfig} instance. - * @throws ExtAuthzParseException if the proto is invalid or contains unsupported features. - */ - public static ExtAuthzConfig fromProto(ExtAuthz extAuthzProto) throws ExtAuthzParseException { - if (!extAuthzProto.hasGrpcService()) { - throw new ExtAuthzParseException( - "unsupported ExtAuthz service type: only grpc_service is " + "supported"); - } - GrpcServiceConfig grpcServiceConfig; - try { - grpcServiceConfig = GrpcServiceConfig.fromProto(extAuthzProto.getGrpcService()); - } catch (GrpcServiceParseException e) { - throw new ExtAuthzParseException("Failed to parse GrpcService config: " + e.getMessage(), e); - } - Builder builder = builder().grpcService(grpcServiceConfig) - .failureModeAllow(extAuthzProto.getFailureModeAllow()) - .failureModeAllowHeaderAdd(extAuthzProto.getFailureModeAllowHeaderAdd()) - .includePeerCertificate(extAuthzProto.getIncludePeerCertificate()) - .denyAtDisable(extAuthzProto.getDenyAtDisable().getDefaultValue().getValue()); - - if (extAuthzProto.hasFilterEnabled()) { - builder.filterEnabled(parsePercent(extAuthzProto.getFilterEnabled().getDefaultValue())); - } - - if (extAuthzProto.hasStatusOnError()) { - builder.statusOnError( - GrpcUtil.httpStatusToGrpcStatus(extAuthzProto.getStatusOnError().getCodeValue())); - } - - if (extAuthzProto.hasAllowedHeaders()) { - builder.allowedHeaders(extAuthzProto.getAllowedHeaders().getPatternsList().stream() - .map(MatcherParser::parseStringMatcher).collect(ImmutableList.toImmutableList())); - } - - if (extAuthzProto.hasDisallowedHeaders()) { - builder.disallowedHeaders(extAuthzProto.getDisallowedHeaders().getPatternsList().stream() - .map(MatcherParser::parseStringMatcher).collect(ImmutableList.toImmutableList())); - } - - if (extAuthzProto.hasDecoderHeaderMutationRules()) { - builder.decoderHeaderMutationRules( - parseHeaderMutationRules(extAuthzProto.getDecoderHeaderMutationRules())); - } - - return builder.build(); - } - /** * The gRPC service configuration for the external authorization service. This is a required * field. @@ -155,7 +96,7 @@ public static ExtAuthzConfig fromProto(ExtAuthz extAuthzProto) throws ExtAuthzPa public abstract Matchers.FractionMatcher filterEnabled(); /** - * Specifies which request headers are sent to the authorization service. If not set, all headers + * Specifies which request headers are sent to the authorization service. If empty, all headers * are sent. * * @see ExtAuthz#getAllowedHeaders() @@ -201,50 +142,4 @@ public abstract static class Builder { public abstract ExtAuthzConfig build(); } - - - private static Matchers.FractionMatcher parsePercent( - io.envoyproxy.envoy.type.v3.FractionalPercent proto) throws ExtAuthzParseException { - int denominator; - switch (proto.getDenominator()) { - case HUNDRED: - denominator = 100; - break; - case TEN_THOUSAND: - denominator = 10_000; - break; - case MILLION: - denominator = 1_000_000; - break; - case UNRECOGNIZED: - default: - throw new ExtAuthzParseException("Unknown denominator type: " + proto.getDenominator()); - } - return Matchers.FractionMatcher.create(proto.getNumerator(), denominator); - } - - private static HeaderMutationRulesConfig parseHeaderMutationRules(HeaderMutationRules proto) - throws ExtAuthzParseException { - HeaderMutationRulesConfig.Builder builder = HeaderMutationRulesConfig.builder(); - builder.disallowAll(proto.getDisallowAll().getValue()); - builder.disallowIsError(proto.getDisallowIsError().getValue()); - if (proto.hasAllowExpression()) { - builder.allowExpression( - parseRegex(proto.getAllowExpression().getRegex(), "allow_expression")); - } - if (proto.hasDisallowExpression()) { - builder.disallowExpression( - parseRegex(proto.getDisallowExpression().getRegex(), "disallow_expression")); - } - return builder.build(); - } - - private static Pattern parseRegex(String regex, String fieldName) throws ExtAuthzParseException { - try { - return Pattern.compile(regex); - } catch (PatternSyntaxException e) { - throw new ExtAuthzParseException( - "Invalid regex pattern for " + fieldName + ": " + e.getMessage(), e); - } - } } diff --git a/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzConfigParser.java b/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzConfigParser.java new file mode 100644 index 00000000000..4e17763ae12 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzConfigParser.java @@ -0,0 +1,96 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.extauthz; + +import com.google.common.collect.ImmutableList; +import io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz; +import io.grpc.internal.GrpcUtil; +import io.grpc.xds.internal.MatcherParser; +import io.grpc.xds.internal.grpcservice.GrpcServiceConfig; +import io.grpc.xds.internal.grpcservice.GrpcServiceConfigParser; +import io.grpc.xds.internal.grpcservice.GrpcServiceParseException; +import io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextProvider; +import io.grpc.xds.internal.headermutations.HeaderMutationRulesParser; + + +/** + * Parser for {@link io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz}. + */ +public final class ExtAuthzConfigParser { + + private ExtAuthzConfigParser() {} + + /** + * Parses the {@link io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz} proto to + * create an {@link ExtAuthzConfig} instance. + * + * @param extAuthzProto The ext_authz proto to parse. + * @return An {@link ExtAuthzConfig} instance. + * @throws ExtAuthzParseException if the proto is invalid or contains unsupported features. + */ + public static ExtAuthzConfig parse( + ExtAuthz extAuthzProto, GrpcServiceXdsContextProvider contextProvider) + throws ExtAuthzParseException { + if (!extAuthzProto.hasGrpcService()) { + throw new ExtAuthzParseException( + "unsupported ExtAuthz service type: only grpc_service is supported"); + } + GrpcServiceConfig grpcServiceConfig; + try { + grpcServiceConfig = + GrpcServiceConfigParser.parse(extAuthzProto.getGrpcService(), contextProvider); + } catch (GrpcServiceParseException e) { + throw new ExtAuthzParseException("Failed to parse GrpcService config: " + e.getMessage(), e); + } + ExtAuthzConfig.Builder builder = ExtAuthzConfig.newBuilder().grpcService(grpcServiceConfig) + .failureModeAllow(extAuthzProto.getFailureModeAllow()) + .failureModeAllowHeaderAdd(extAuthzProto.getFailureModeAllowHeaderAdd()) + .includePeerCertificate(extAuthzProto.getIncludePeerCertificate()) + .denyAtDisable(extAuthzProto.getDenyAtDisable().getDefaultValue().getValue()); + + if (extAuthzProto.hasFilterEnabled()) { + try { + builder.filterEnabled( + MatcherParser.parseFractionMatcher(extAuthzProto.getFilterEnabled().getDefaultValue())); + } catch (IllegalArgumentException e) { + throw new ExtAuthzParseException(e.getMessage()); + } + } + + if (extAuthzProto.hasStatusOnError()) { + builder.statusOnError( + GrpcUtil.httpStatusToGrpcStatus(extAuthzProto.getStatusOnError().getCodeValue())); + } + + if (extAuthzProto.hasAllowedHeaders()) { + builder.allowedHeaders(extAuthzProto.getAllowedHeaders().getPatternsList().stream() + .map(MatcherParser::parseStringMatcher).collect(ImmutableList.toImmutableList())); + } + + if (extAuthzProto.hasDisallowedHeaders()) { + builder.disallowedHeaders(extAuthzProto.getDisallowedHeaders().getPatternsList().stream() + .map(MatcherParser::parseStringMatcher).collect(ImmutableList.toImmutableList())); + } + + if (extAuthzProto.hasDecoderHeaderMutationRules()) { + builder.decoderHeaderMutationRules( + HeaderMutationRulesParser.parse(extAuthzProto.getDecoderHeaderMutationRules())); + } + + return builder.build(); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigChannelFactory.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/ChannelCredsConfig.java similarity index 74% rename from xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigChannelFactory.java rename to xds/src/main/java/io/grpc/xds/internal/grpcservice/ChannelCredsConfig.java index 0d02989eaa3..1e7008ca8e2 100644 --- a/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigChannelFactory.java +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/ChannelCredsConfig.java @@ -16,11 +16,12 @@ package io.grpc.xds.internal.grpcservice; -import io.grpc.ManagedChannel; - /** - * A factory for creating {@link ManagedChannel}s from a {@link GrpcServiceConfig}. + * Configuration for channel credentials. */ -public interface GrpcServiceConfigChannelFactory { - ManagedChannel createChannel(GrpcServiceConfig config); +public interface ChannelCredsConfig { + /** + * Returns the type of the credentials. + */ + String type(); } diff --git a/xds/src/main/java/io/grpc/xds/internal/grpcservice/ConfiguredChannelCredentials.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/ConfiguredChannelCredentials.java new file mode 100644 index 00000000000..bf541748cd8 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/ConfiguredChannelCredentials.java @@ -0,0 +1,35 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.grpcservice; + +import com.google.auto.value.AutoValue; +import io.grpc.ChannelCredentials; + +/** + * Composition of {@link ChannelCredentials} and {@link ChannelCredsConfig}. + */ +@AutoValue +public abstract class ConfiguredChannelCredentials { + public abstract ChannelCredentials channelCredentials(); + + public abstract ChannelCredsConfig channelCredsConfig(); + + public static ConfiguredChannelCredentials create(ChannelCredentials creds, + ChannelCredsConfig config) { + return new AutoValue_ConfiguredChannelCredentials(creds, config); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfig.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfig.java index da9be978f87..ba0a9808025 100644 --- a/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfig.java +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfig.java @@ -16,93 +16,30 @@ package io.grpc.xds.internal.grpcservice; -import com.google.auth.oauth2.AccessToken; -import com.google.auth.oauth2.OAuth2Credentials; import com.google.auto.value.AutoValue; -import com.google.common.io.BaseEncoding; -import com.google.protobuf.Any; -import com.google.protobuf.InvalidProtocolBufferException; -import io.envoyproxy.envoy.config.core.v3.GrpcService; -import io.envoyproxy.envoy.extensions.grpc_service.call_credentials.access_token.v3.AccessTokenCredentials; -import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.xds.v3.XdsCredentials; +import com.google.common.collect.ImmutableList; import io.grpc.CallCredentials; -import io.grpc.ChannelCredentials; -import io.grpc.InsecureChannelCredentials; -import io.grpc.Metadata; -import io.grpc.alts.GoogleDefaultChannelCredentials; -import io.grpc.auth.MoreCallCredentials; -import io.grpc.xds.XdsChannelCredentials; import java.time.Duration; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; import java.util.Optional; /** - * A Java representation of the {@link io.envoyproxy.envoy.config.core.v3.GrpcService} proto, - * designed for parsing and internal use within gRPC. This class encapsulates the configuration for - * a gRPC service, including target URI, credentials, and other settings. The parsing logic adheres - * to the specifications outlined in - * A102: xDS GrpcService Support. This class is immutable and uses the AutoValue library for its - * implementation. + * A Java representation of the {@link io.envoyproxy.envoy.config.core.v3.GrpcService} proto. This + * class encapsulates the configuration for a gRPC service, including target URI, credentials, and + * other settings. This class is immutable and uses the AutoValue library for its implementation. */ @AutoValue public abstract class GrpcServiceConfig { - public static Builder builder() { + public static Builder newBuilder() { return new AutoValue_GrpcServiceConfig.Builder(); } - /** - * Parses the {@link io.envoyproxy.envoy.config.core.v3.GrpcService} proto to create a - * {@link GrpcServiceConfig} instance. This method adheres to gRFC A102, which specifies that only - * the {@code google_grpc} target specifier is supported. Other fields like {@code timeout} and - * {@code initial_metadata} are also parsed as per the gRFC. - * - * @param grpcServiceProto The proto to parse. - * @return A {@link GrpcServiceConfig} instance. - * @throws GrpcServiceParseException if the proto is invalid or uses unsupported features. - */ - public static GrpcServiceConfig fromProto(GrpcService grpcServiceProto) - throws GrpcServiceParseException { - if (!grpcServiceProto.hasGoogleGrpc()) { - throw new GrpcServiceParseException( - "Unsupported: GrpcService must have GoogleGrpc, got: " + grpcServiceProto); - } - GoogleGrpcConfig googleGrpcConfig = - GoogleGrpcConfig.fromProto(grpcServiceProto.getGoogleGrpc()); - - Builder builder = GrpcServiceConfig.builder().googleGrpc(googleGrpcConfig); - - if (!grpcServiceProto.getInitialMetadataList().isEmpty()) { - Metadata initialMetadata = new Metadata(); - for (io.envoyproxy.envoy.config.core.v3.HeaderValue header : grpcServiceProto - .getInitialMetadataList()) { - String key = header.getKey(); - if (key.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { - initialMetadata.put(Metadata.Key.of(key, Metadata.BINARY_BYTE_MARSHALLER), - BaseEncoding.base64().decode(header.getValue())); - } else { - initialMetadata.put(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER), - header.getValue()); - } - } - builder.initialMetadata(initialMetadata); - } - - if (grpcServiceProto.hasTimeout()) { - com.google.protobuf.Duration timeout = grpcServiceProto.getTimeout(); - builder.timeout(Duration.ofSeconds(timeout.getSeconds(), timeout.getNanos())); - } - return builder.build(); - } - public abstract GoogleGrpcConfig googleGrpc(); public abstract Optional timeout(); - public abstract Optional initialMetadata(); + public abstract ImmutableList initialMetadata(); @AutoValue.Builder public abstract static class Builder { @@ -110,7 +47,7 @@ public abstract static class Builder { public abstract Builder timeout(Duration timeout); - public abstract Builder initialMetadata(Metadata initialMetadata); + public abstract Builder initialMetadata(ImmutableList initialMetadata); public abstract GrpcServiceConfig build(); } @@ -119,190 +56,33 @@ public abstract static class Builder { * Represents the configuration for a Google gRPC service, as defined in the * {@link io.envoyproxy.envoy.config.core.v3.GrpcService.GoogleGrpc} proto. This class * encapsulates settings specific to Google's gRPC implementation, such as target URI and - * credentials. The parsing of this configuration is guided by gRFC A102, which specifies how gRPC - * clients should interpret the GrpcService proto. + * credentials. */ @AutoValue public abstract static class GoogleGrpcConfig { - private static final String TLS_CREDENTIALS_TYPE_URL = - "type.googleapis.com/envoy.extensions.grpc_service.channel_credentials." - + "tls.v3.TlsCredentials"; - private static final String LOCAL_CREDENTIALS_TYPE_URL = - "type.googleapis.com/envoy.extensions.grpc_service.channel_credentials." - + "local.v3.LocalCredentials"; - private static final String XDS_CREDENTIALS_TYPE_URL = - "type.googleapis.com/envoy.extensions.grpc_service.channel_credentials." - + "xds.v3.XdsCredentials"; - private static final String INSECURE_CREDENTIALS_TYPE_URL = - "type.googleapis.com/envoy.extensions.grpc_service.channel_credentials." - + "insecure.v3.InsecureCredentials"; - private static final String GOOGLE_DEFAULT_CREDENTIALS_TYPE_URL = - "type.googleapis.com/envoy.extensions.grpc_service.channel_credentials." - + "google_default.v3.GoogleDefaultCredentials"; - public static Builder builder() { return new AutoValue_GrpcServiceConfig_GoogleGrpcConfig.Builder(); } - /** - * Parses the {@link io.envoyproxy.envoy.config.core.v3.GrpcService.GoogleGrpc} proto to create - * a {@link GoogleGrpcConfig} instance. - * - * @param googleGrpcProto The proto to parse. - * @return A {@link GoogleGrpcConfig} instance. - * @throws GrpcServiceParseException if the proto is invalid. - */ - public static GoogleGrpcConfig fromProto(GrpcService.GoogleGrpc googleGrpcProto) - throws GrpcServiceParseException { - - HashedChannelCredentials channelCreds = - extractChannelCredentials(googleGrpcProto.getChannelCredentialsPluginList()); - - CallCredentials callCreds = - extractCallCredentials(googleGrpcProto.getCallCredentialsPluginList()); - - return GoogleGrpcConfig.builder().target(googleGrpcProto.getTargetUri()) - .hashedChannelCredentials(channelCreds).callCredentials(callCreds).build(); - } - public abstract String target(); - public abstract HashedChannelCredentials hashedChannelCredentials(); + public abstract ConfiguredChannelCredentials configuredChannelCredentials(); - public abstract CallCredentials callCredentials(); + public abstract Optional callCredentials(); @AutoValue.Builder public abstract static class Builder { public abstract Builder target(String target); - public abstract Builder hashedChannelCredentials(HashedChannelCredentials channelCredentials); + public abstract Builder configuredChannelCredentials( + ConfiguredChannelCredentials channelCredentials); public abstract Builder callCredentials(CallCredentials callCredentials); public abstract GoogleGrpcConfig build(); } - - private static T getFirstSupported(List configs, Parser parser, - String configName) throws GrpcServiceParseException { - List errors = new ArrayList<>(); - for (U config : configs) { - try { - return parser.parse(config); - } catch (GrpcServiceParseException e) { - errors.add(e.getMessage()); - } - } - throw new GrpcServiceParseException( - "No valid supported " + configName + " found. Errors: " + errors); - } - - private static HashedChannelCredentials channelCredsFromProto(Any cred) - throws GrpcServiceParseException { - String typeUrl = cred.getTypeUrl(); - try { - switch (typeUrl) { - case GOOGLE_DEFAULT_CREDENTIALS_TYPE_URL: - return HashedChannelCredentials.of(GoogleDefaultChannelCredentials.create(), - cred.hashCode()); - case INSECURE_CREDENTIALS_TYPE_URL: - return HashedChannelCredentials.of(InsecureChannelCredentials.create(), - cred.hashCode()); - case XDS_CREDENTIALS_TYPE_URL: - XdsCredentials xdsConfig = cred.unpack(XdsCredentials.class); - HashedChannelCredentials fallbackCreds = - channelCredsFromProto(xdsConfig.getFallbackCredentials()); - return HashedChannelCredentials.of( - XdsChannelCredentials.create(fallbackCreds.channelCredentials()), cred.hashCode()); - case LOCAL_CREDENTIALS_TYPE_URL: - // TODO(sauravzg) : What's the java alternative to LocalCredentials. - throw new GrpcServiceParseException("LocalCredentials are not yet supported."); - case TLS_CREDENTIALS_TYPE_URL: - // TODO(sauravzg) : How to instantiate a TlsChannelCredentials from TlsCredentials - // proto? - throw new GrpcServiceParseException("TlsCredentials are not yet supported."); - default: - throw new GrpcServiceParseException("Unsupported channel credentials type: " + typeUrl); - } - } catch (InvalidProtocolBufferException e) { - // TODO(sauravzg): Add unit tests when we have a solution for TLS creds. - // This code is as of writing unreachable because all channel credential message - // types except TLS are empty messages. - throw new GrpcServiceParseException( - "Failed to parse channel credentials: " + e.getMessage()); - } - } - - private static CallCredentials callCredsFromProto(Any cred) throws GrpcServiceParseException { - try { - AccessTokenCredentials accessToken = cred.unpack(AccessTokenCredentials.class); - // TODO(sauravzg): Verify if the current behavior is per spec.The `AccessTokenCredentials` - // config doesn't have any timeout/refresh, so set the token to never expire. - return MoreCallCredentials.from(OAuth2Credentials - .create(new AccessToken(accessToken.getToken(), new Date(Long.MAX_VALUE)))); - } catch (InvalidProtocolBufferException e) { - throw new GrpcServiceParseException( - "Unsupported call credentials type: " + cred.getTypeUrl()); - } - } - - private static HashedChannelCredentials extractChannelCredentials( - List channelCredentialPlugins) throws GrpcServiceParseException { - return getFirstSupported(channelCredentialPlugins, GoogleGrpcConfig::channelCredsFromProto, - "channel_credentials"); - } - - private static CallCredentials extractCallCredentials(List callCredentialPlugins) - throws GrpcServiceParseException { - return getFirstSupported(callCredentialPlugins, GoogleGrpcConfig::callCredsFromProto, - "call_credentials"); - } - } - - /** - * A container for {@link ChannelCredentials} and a hash for the purpose of caching. - */ - @AutoValue - public abstract static class HashedChannelCredentials { - /** - * Creates a new {@link HashedChannelCredentials} instance. - * - * @param creds The channel credentials. - * @param hash The hash of the credentials. - * @return A new {@link HashedChannelCredentials} instance. - */ - public static HashedChannelCredentials of(ChannelCredentials creds, int hash) { - return new AutoValue_GrpcServiceConfig_HashedChannelCredentials(creds, hash); - } - - /** - * Returns the channel credentials. - */ - public abstract ChannelCredentials channelCredentials(); - - /** - * Returns the hash of the credentials. - */ - public abstract int hash(); } - /** - * Defines a generic interface for parsing a configuration of type {@code U} into a result of type - * {@code T}. This functional interface is used to abstract the parsing logic for different parts - * of the GrpcService configuration. - * - * @param The type of the object that will be returned after parsing. - * @param The type of the configuration object that will be parsed. - */ - private interface Parser { - /** - * Parses the given configuration. - * - * @param config The configuration object to parse. - * @return The parsed object of type {@code T}. - * @throws GrpcServiceParseException if an error occurs during parsing. - */ - T parse(U config) throws GrpcServiceParseException; - } } diff --git a/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigParser.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigParser.java new file mode 100644 index 00000000000..7614484f396 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigParser.java @@ -0,0 +1,323 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.grpcservice; + +import com.google.auth.oauth2.AccessToken; +import com.google.auth.oauth2.OAuth2Credentials; +import com.google.common.collect.ImmutableList; +import com.google.protobuf.Any; +import com.google.protobuf.InvalidProtocolBufferException; +import io.envoyproxy.envoy.config.core.v3.GrpcService; +import io.envoyproxy.envoy.extensions.grpc_service.call_credentials.access_token.v3.AccessTokenCredentials; +import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.xds.v3.XdsCredentials; +import io.grpc.CallCredentials; +import io.grpc.CompositeCallCredentials; +import io.grpc.InsecureChannelCredentials; +import io.grpc.Metadata; +import io.grpc.SecurityLevel; +import io.grpc.Status; +import io.grpc.alts.GoogleDefaultChannelCredentials; +import io.grpc.auth.MoreCallCredentials; +import io.grpc.xds.XdsChannelCredentials; +import io.grpc.xds.internal.XdsHeaderValidator; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.Executor; + +/** + * Parser for {@link io.envoyproxy.envoy.config.core.v3.GrpcService} and related protos. + */ +public final class GrpcServiceConfigParser { + + static final String TLS_CREDENTIALS_TYPE_URL = + "type.googleapis.com/envoy.extensions.grpc_service.channel_credentials." + + "tls.v3.TlsCredentials"; + static final String LOCAL_CREDENTIALS_TYPE_URL = + "type.googleapis.com/envoy.extensions.grpc_service.channel_credentials." + + "local.v3.LocalCredentials"; + static final String XDS_CREDENTIALS_TYPE_URL = + "type.googleapis.com/envoy.extensions.grpc_service.channel_credentials." + + "xds.v3.XdsCredentials"; + static final String INSECURE_CREDENTIALS_TYPE_URL = + "type.googleapis.com/envoy.extensions.grpc_service.channel_credentials." + + "insecure.v3.InsecureCredentials"; + static final String GOOGLE_DEFAULT_CREDENTIALS_TYPE_URL = + "type.googleapis.com/envoy.extensions.grpc_service.channel_credentials." + + "google_default.v3.GoogleDefaultCredentials"; + + private GrpcServiceConfigParser() {} + + /** + * Parses the {@link io.envoyproxy.envoy.config.core.v3.GrpcService} proto to create a + * {@link GrpcServiceConfig} instance. + * + * @param grpcServiceProto The proto to parse. + * @return A {@link GrpcServiceConfig} instance. + * @throws GrpcServiceParseException if the proto is invalid or uses unsupported features. + */ + public static GrpcServiceConfig parse(GrpcService grpcServiceProto, + GrpcServiceXdsContextProvider contextProvider) + throws GrpcServiceParseException { + if (!grpcServiceProto.hasGoogleGrpc()) { + throw new GrpcServiceParseException( + "Unsupported: GrpcService must have GoogleGrpc, got: " + grpcServiceProto); + } + GrpcServiceConfig.GoogleGrpcConfig googleGrpcConfig = + parseGoogleGrpcConfig(grpcServiceProto.getGoogleGrpc(), contextProvider); + + GrpcServiceConfig.Builder builder = GrpcServiceConfig.newBuilder().googleGrpc(googleGrpcConfig); + + ImmutableList.Builder initialMetadata = ImmutableList.builder(); + for (io.envoyproxy.envoy.config.core.v3.HeaderValue header : grpcServiceProto + .getInitialMetadataList()) { + String key = header.getKey(); + if (key.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + if (!XdsHeaderValidator.isValid(key, header.getRawValue().size())) { + throw new GrpcServiceParseException("Invalid initial metadata header: " + key); + } + initialMetadata.add(HeaderValue.create(key, header.getRawValue())); + } else { + if (!XdsHeaderValidator.isValid(key, header.getValue().length())) { + throw new GrpcServiceParseException("Invalid initial metadata header: " + key); + } + initialMetadata.add(HeaderValue.create(key, header.getValue())); + } + } + builder.initialMetadata(initialMetadata.build()); + + if (grpcServiceProto.hasTimeout()) { + com.google.protobuf.Duration timeout = grpcServiceProto.getTimeout(); + if (timeout.getSeconds() < 0 || timeout.getNanos() < 0 + || (timeout.getSeconds() == 0 && timeout.getNanos() == 0)) { + throw new GrpcServiceParseException("Timeout must be strictly positive"); + } + builder.timeout(Duration.ofSeconds(timeout.getSeconds(), timeout.getNanos())); + } + return builder.build(); + } + + /** + * Parses the {@link io.envoyproxy.envoy.config.core.v3.GrpcService.GoogleGrpc} proto to create a + * {@link GrpcServiceConfig.GoogleGrpcConfig} instance. + * + * @param googleGrpcProto The proto to parse. + * @return A {@link GrpcServiceConfig.GoogleGrpcConfig} instance. + * @throws GrpcServiceParseException if the proto is invalid. + */ + public static GrpcServiceConfig.GoogleGrpcConfig parseGoogleGrpcConfig( + GrpcService.GoogleGrpc googleGrpcProto, GrpcServiceXdsContextProvider contextProvider) + throws GrpcServiceParseException { + + String targetUri = googleGrpcProto.getTargetUri(); + GrpcServiceXdsContext context = contextProvider.getContextForTarget(targetUri); + + if (!context.isTargetUriSchemeSupported()) { + throw new GrpcServiceParseException("Target URI scheme is not resolvable: " + targetUri); + } + + if (!context.isTrustedControlPlane()) { + Optional override = + context.validAllowedGrpcService(); + if (!override.isPresent()) { + throw new GrpcServiceParseException( + "Untrusted xDS server & URI not found in allowed_grpc_services: " + targetUri); + } + + GrpcServiceConfig.GoogleGrpcConfig.Builder builder = + GrpcServiceConfig.GoogleGrpcConfig.builder() + .target(targetUri) + .configuredChannelCredentials(override.get().configuredChannelCredentials()); + if (override.get().callCredentials().isPresent()) { + builder.callCredentials(override.get().callCredentials().get()); + } + return builder.build(); + } + + ConfiguredChannelCredentials channelCreds = null; + if (googleGrpcProto.getChannelCredentialsPluginCount() > 0) { + try { + channelCreds = extractChannelCredentials(googleGrpcProto.getChannelCredentialsPluginList()); + } catch (GrpcServiceParseException e) { + // Fall back to channel_credentials if plugins are not supported + } + } + + if (channelCreds == null) { + throw new GrpcServiceParseException("No valid supported channel_credentials found"); + } + + Optional callCreds = + extractCallCredentials(googleGrpcProto.getCallCredentialsPluginList()); + + GrpcServiceConfig.GoogleGrpcConfig.Builder builder = + GrpcServiceConfig.GoogleGrpcConfig.builder().target(googleGrpcProto.getTargetUri()) + .configuredChannelCredentials(channelCreds); + if (callCreds.isPresent()) { + builder.callCredentials(callCreds.get()); + } + return builder.build(); + } + + private static Optional channelCredsFromProto( + Any cred) throws GrpcServiceParseException { + String typeUrl = cred.getTypeUrl(); + try { + switch (typeUrl) { + case GOOGLE_DEFAULT_CREDENTIALS_TYPE_URL: + return Optional.of(ConfiguredChannelCredentials.create( + GoogleDefaultChannelCredentials.create(), + new ProtoChannelCredsConfig(typeUrl, cred))); + case INSECURE_CREDENTIALS_TYPE_URL: + return Optional.of(ConfiguredChannelCredentials.create( + InsecureChannelCredentials.create(), + new ProtoChannelCredsConfig(typeUrl, cred))); + case XDS_CREDENTIALS_TYPE_URL: + XdsCredentials xdsConfig = cred.unpack(XdsCredentials.class); + Optional fallbackCreds = + channelCredsFromProto(xdsConfig.getFallbackCredentials()); + if (!fallbackCreds.isPresent()) { + throw new GrpcServiceParseException( + "Unsupported fallback credentials type for XdsCredentials"); + } + return Optional.of(ConfiguredChannelCredentials.create( + XdsChannelCredentials.create(fallbackCreds.get().channelCredentials()), + new ProtoChannelCredsConfig(typeUrl, cred))); + case LOCAL_CREDENTIALS_TYPE_URL: + throw new UnsupportedOperationException( + "LocalCredentials are not supported in grpc-java. " + + "See https://github.com/grpc/grpc-java/issues/8928"); + case TLS_CREDENTIALS_TYPE_URL: + // For this PR, we establish this structural skeleton, + // but throw an UnsupportedOperationException until the exact stream conversions are + // merged. + throw new UnsupportedOperationException( + "TlsCredentials input stream construction pending."); + default: + return Optional.empty(); + } + } catch (InvalidProtocolBufferException e) { + throw new GrpcServiceParseException("Failed to parse channel credentials: " + e.getMessage()); + } + } + + private static ConfiguredChannelCredentials extractChannelCredentials( + List channelCredentialPlugins) throws GrpcServiceParseException { + for (Any cred : channelCredentialPlugins) { + Optional parsed = channelCredsFromProto(cred); + if (parsed.isPresent()) { + return parsed.get(); + } + } + throw new GrpcServiceParseException("No valid supported channel_credentials found"); + } + + private static Optional callCredsFromProto(Any cred) + throws GrpcServiceParseException { + if (cred.is(AccessTokenCredentials.class)) { + try { + AccessTokenCredentials accessToken = cred.unpack(AccessTokenCredentials.class); + if (accessToken.getToken().isEmpty()) { + throw new GrpcServiceParseException("Missing or empty access token in call credentials."); + } + return Optional + .of(new SecurityAwareAccessTokenCredentials(MoreCallCredentials.from(OAuth2Credentials + .create(new AccessToken(accessToken.getToken(), new Date(Long.MAX_VALUE)))))); + } catch (InvalidProtocolBufferException e) { + throw new GrpcServiceParseException( + "Failed to parse access token credentials: " + e.getMessage()); + } + } + return Optional.empty(); + } + + private static Optional extractCallCredentials(List callCredentialPlugins) + throws GrpcServiceParseException { + List creds = new ArrayList<>(); + for (Any cred : callCredentialPlugins) { + Optional parsed = callCredsFromProto(cred); + if (parsed.isPresent()) { + creds.add(parsed.get()); + } + } + return creds.stream().reduce(CompositeCallCredentials::new); + } + + private static final class SecurityAwareAccessTokenCredentials extends CallCredentials { + + private final CallCredentials delegate; + + SecurityAwareAccessTokenCredentials(CallCredentials delegate) { + this.delegate = delegate; + } + + @Override + public void applyRequestMetadata(RequestInfo requestInfo, Executor appExecutor, + MetadataApplier applier) { + if (requestInfo.getSecurityLevel() != SecurityLevel.PRIVACY_AND_INTEGRITY) { + applier.fail(Status.UNAUTHENTICATED.withDescription( + "OAuth2 credentials require connection with PRIVACY_AND_INTEGRITY security level")); + return; + } + delegate.applyRequestMetadata(requestInfo, appExecutor, applier); + } + } + + + + static final class ProtoChannelCredsConfig implements ChannelCredsConfig { + private final String type; + private final Any configProto; + + ProtoChannelCredsConfig(String type, Any configProto) { + this.type = type; + this.configProto = configProto; + } + + @Override + public String type() { + return type; + } + + Any configProto() { + return configProto; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ProtoChannelCredsConfig that = (ProtoChannelCredsConfig) o; + return java.util.Objects.equals(type, that.type) + && java.util.Objects.equals(configProto, that.configProto); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(type, configProto); + } + } + + + +} diff --git a/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceXdsContext.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceXdsContext.java new file mode 100644 index 00000000000..77ae8cffe03 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceXdsContext.java @@ -0,0 +1,71 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.grpcservice; + +import com.google.auto.value.AutoValue; +import io.grpc.CallCredentials; +import io.grpc.Internal; +import java.util.Optional; + +/** + * Contextual abstraction needed during xDS plugin parsing. + * Represents the context for a single target URI. + */ +@AutoValue +@Internal +public abstract class GrpcServiceXdsContext { + + public abstract boolean isTrustedControlPlane(); + + public abstract Optional validAllowedGrpcService(); + + public abstract boolean isTargetUriSchemeSupported(); + + public static GrpcServiceXdsContext create( + boolean isTrustedControlPlane, + Optional validAllowedGrpcService, + boolean isTargetUriSchemeSupported) { + return new AutoValue_GrpcServiceXdsContext( + isTrustedControlPlane, + validAllowedGrpcService, + isTargetUriSchemeSupported); + } + + /** + * Represents an allowed gRPC service configuration with local credentials. + */ + @AutoValue + public abstract static class AllowedGrpcService { + public abstract ConfiguredChannelCredentials configuredChannelCredentials(); + + public abstract Optional callCredentials(); + + public static Builder builder() { + return new AutoValue_GrpcServiceXdsContext_AllowedGrpcService.Builder(); + } + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder configuredChannelCredentials( + ConfiguredChannelCredentials credentials); + + public abstract Builder callCredentials(CallCredentials callCredentials); + + public abstract AllowedGrpcService build(); + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceXdsContextProvider.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceXdsContextProvider.java new file mode 100644 index 00000000000..411a9e06977 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceXdsContextProvider.java @@ -0,0 +1,31 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.grpcservice; + +import io.grpc.Internal; + +/** + * Provider interface to retrieve target-specific xDS context. + */ +@Internal +public interface GrpcServiceXdsContextProvider { + + /** + * Returns the `GrpcServiceXdsContext` for the given internal target URI. + */ + GrpcServiceXdsContext getContextForTarget(String targetUri); +} diff --git a/xds/src/main/java/io/grpc/xds/internal/grpcservice/HeaderValue.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/HeaderValue.java new file mode 100644 index 00000000000..1b7bb283744 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/HeaderValue.java @@ -0,0 +1,44 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.grpcservice; + +import com.google.auto.value.AutoValue; +import com.google.protobuf.ByteString; +import java.util.Optional; + +/** + * Represents a header to be mutated or added as part of xDS configuration. + * Avoids direct dependency on Envoy's proto objects while providing an immutable representation. + */ +@AutoValue +public abstract class HeaderValue { + + public static HeaderValue create(String key, String value) { + return new AutoValue_HeaderValue(key, Optional.of(value), Optional.empty()); + } + + public static HeaderValue create(String key, ByteString rawValue) { + return new AutoValue_HeaderValue(key, Optional.empty(), Optional.of(rawValue)); + } + + + public abstract String key(); + + public abstract Optional value(); + + public abstract Optional rawValue(); +} diff --git a/xds/src/main/java/io/grpc/xds/internal/grpcservice/InsecureGrpcChannelFactory.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/InsecureGrpcChannelFactory.java deleted file mode 100644 index d6325d43be4..00000000000 --- a/xds/src/main/java/io/grpc/xds/internal/grpcservice/InsecureGrpcChannelFactory.java +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2025 The gRPC Authors - * - * Licensed 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 io.grpc.xds.internal.grpcservice; - -import io.grpc.Grpc; -import io.grpc.ManagedChannel; - -/** - * An insecure implementation of {@link GrpcServiceConfigChannelFactory} that creates a plaintext - * channel. This is a stub implementation for channel creation until the GrpcService trusted server - * implementation is completely implemented. - */ -public final class InsecureGrpcChannelFactory implements GrpcServiceConfigChannelFactory { - - private static final InsecureGrpcChannelFactory INSTANCE = new InsecureGrpcChannelFactory(); - - private InsecureGrpcChannelFactory() {} - - public static InsecureGrpcChannelFactory getInstance() { - return INSTANCE; - } - - @Override - public ManagedChannel createChannel(GrpcServiceConfig config) { - GrpcServiceConfig.GoogleGrpcConfig googleGrpc = config.googleGrpc(); - return Grpc.newChannelBuilder(googleGrpc.target(), - googleGrpc.hashedChannelCredentials().channelCredentials()).build(); - } -} diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfig.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfig.java index fd8048fdbd2..249a587ce53 100644 --- a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfig.java +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfig.java @@ -17,9 +17,9 @@ package io.grpc.xds.internal.headermutations; import com.google.auto.value.AutoValue; +import com.google.re2j.Pattern; import io.envoyproxy.envoy.config.common.mutation_rules.v3.HeaderMutationRules; import java.util.Optional; -import java.util.regex.Pattern; /** * Represents the configuration for header mutation rules, as defined in the diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesParser.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesParser.java new file mode 100644 index 00000000000..b00db519d45 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesParser.java @@ -0,0 +1,55 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.headermutations; + +import com.google.re2j.Pattern; +import com.google.re2j.PatternSyntaxException; +import io.envoyproxy.envoy.config.common.mutation_rules.v3.HeaderMutationRules; +import io.grpc.xds.internal.extauthz.ExtAuthzParseException; + +/** + * Parser for {@link io.envoyproxy.envoy.config.common.mutation_rules.v3.HeaderMutationRules}. + */ +public final class HeaderMutationRulesParser { + + private HeaderMutationRulesParser() {} + + public static HeaderMutationRulesConfig parse(HeaderMutationRules proto) + throws ExtAuthzParseException { + HeaderMutationRulesConfig.Builder builder = HeaderMutationRulesConfig.builder(); + builder.disallowAll(proto.getDisallowAll().getValue()); + builder.disallowIsError(proto.getDisallowIsError().getValue()); + if (proto.hasAllowExpression()) { + builder.allowExpression( + parseRegex(proto.getAllowExpression().getRegex(), "allow_expression")); + } + if (proto.hasDisallowExpression()) { + builder.disallowExpression( + parseRegex(proto.getDisallowExpression().getRegex(), "disallow_expression")); + } + return builder.build(); + } + + private static Pattern parseRegex(String regex, String fieldName) throws ExtAuthzParseException { + try { + return Pattern.compile(regex); + } catch (PatternSyntaxException e) { + throw new ExtAuthzParseException( + "Invalid regex pattern for " + fieldName + ": " + e.getMessage(), e); + } + } +} diff --git a/xds/src/test/java/io/grpc/xds/GrpcBootstrapperImplTest.java b/xds/src/test/java/io/grpc/xds/GrpcBootstrapperImplTest.java index 0a303b7255d..b72658a9bf6 100644 --- a/xds/src/test/java/io/grpc/xds/GrpcBootstrapperImplTest.java +++ b/xds/src/test/java/io/grpc/xds/GrpcBootstrapperImplTest.java @@ -37,6 +37,7 @@ import io.grpc.xds.client.EnvoyProtoData.Node; import io.grpc.xds.client.Locality; import io.grpc.xds.client.XdsInitializationException; +import io.grpc.xds.internal.grpcservice.GrpcServiceXdsContext.AllowedGrpcService; import java.io.IOException; import java.util.List; import java.util.Map; @@ -97,6 +98,60 @@ public void parseBootstrap_emptyServers_throws() { assertThat(e).hasMessageThat().isEqualTo("Invalid bootstrap: 'xds_servers' is empty"); } + @Test + public void parseBootstrap_allowedGrpcServices() throws XdsInitializationException { + String rawData = "{\n" + + " \"xds_servers\": [\n" + + " {\n" + + " \"server_uri\": \"" + SERVER_URI + "\",\n" + + " \"channel_creds\": [{\"type\": \"insecure\"}]\n" + + " }\n" + + " ],\n" + + " \"allowed_grpc_services\": {\n" + + " \"dns:///foo.com:443\": {\n" + + " \"channel_creds\": [{\"type\": \"insecure\"}],\n" + + " \"call_creds\": [{\"type\": \"access_token\"}]\n" + + " }\n" + + " }\n" + + "}"; + + bootstrapper.setFileReader(createFileReader(BOOTSTRAP_FILE_PATH, rawData)); + BootstrapInfo info = bootstrapper.bootstrap(); + @SuppressWarnings("unchecked") + Map allowed = + (Map) info.allowedGrpcServices().get(); + + assertThat(allowed).isNotNull(); + assertThat(allowed).containsKey("dns:///foo.com:443"); + AllowedGrpcService service = allowed.get("dns:///foo.com:443"); + assertThat(service.configuredChannelCredentials().channelCredentials()) + .isInstanceOf(InsecureChannelCredentials.class); + assertThat(service.callCredentials().isPresent()).isFalse(); + } + + @Test + public void parseBootstrap_allowedGrpcServices_invalidChannelCreds() { + String rawData = "{\n" + + " \"xds_servers\": [\n" + + " {\n" + + " \"server_uri\": \"" + SERVER_URI + "\",\n" + + " \"channel_creds\": [{\"type\": \"insecure\"}]\n" + + " }\n" + + " ],\n" + + " \"allowed_grpc_services\": {\n" + + " \"dns:///foo.com:443\": {\n" + + " \"channel_creds\": []\n" + + " }\n" + + " }\n" + + "}"; + + bootstrapper.setFileReader(createFileReader(BOOTSTRAP_FILE_PATH, rawData)); + XdsInitializationException e = assertThrows(XdsInitializationException.class, + bootstrapper::bootstrap); + assertThat(e).hasMessageThat() + .isEqualTo("Invalid bootstrap: server dns:///foo.com:443 'channel_creds' required"); + } + @Test public void parseBootstrap_singleXdsServer() throws XdsInitializationException { String rawData = "{\n" diff --git a/xds/src/test/java/io/grpc/xds/internal/MatcherParserTest.java b/xds/src/test/java/io/grpc/xds/internal/MatcherParserTest.java new file mode 100644 index 00000000000..86a6a95fd4b --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/MatcherParserTest.java @@ -0,0 +1,85 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import io.envoyproxy.envoy.type.matcher.v3.RegexMatcher; +import io.envoyproxy.envoy.type.matcher.v3.StringMatcher; +import io.envoyproxy.envoy.type.v3.FractionalPercent; +import io.envoyproxy.envoy.type.v3.FractionalPercent.DenominatorType; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class MatcherParserTest { + + @Test + public void parseStringMatcher_exact() { + StringMatcher proto = + StringMatcher.newBuilder().setExact("exact-match").setIgnoreCase(true).build(); + Matchers.StringMatcher matcher = MatcherParser.parseStringMatcher(proto); + assertThat(matcher).isNotNull(); + } + + @Test + public void parseStringMatcher_allTypes() { + MatcherParser.parseStringMatcher(StringMatcher.newBuilder().setExact("test").build()); + MatcherParser.parseStringMatcher(StringMatcher.newBuilder().setPrefix("test").build()); + MatcherParser.parseStringMatcher(StringMatcher.newBuilder().setSuffix("test").build()); + MatcherParser.parseStringMatcher(StringMatcher.newBuilder().setContains("test").build()); + MatcherParser.parseStringMatcher(StringMatcher.newBuilder() + .setSafeRegex(RegexMatcher.newBuilder().setRegex(".*").build()).build()); + } + + @Test + public void parseStringMatcher_unknownTypeThrows() { + StringMatcher unknownProto = StringMatcher.getDefaultInstance(); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> MatcherParser.parseStringMatcher(unknownProto)); + assertThat(exception).hasMessageThat().contains("Unknown StringMatcher match pattern"); + } + + @Test + public void parseFractionMatcher_denominators() { + Matchers.FractionMatcher hundred = MatcherParser.parseFractionMatcher(FractionalPercent + .newBuilder().setNumerator(1).setDenominator(DenominatorType.HUNDRED).build()); + assertThat(hundred.numerator()).isEqualTo(1); + assertThat(hundred.denominator()).isEqualTo(100); + + Matchers.FractionMatcher tenThousand = MatcherParser.parseFractionMatcher(FractionalPercent + .newBuilder().setNumerator(2).setDenominator(DenominatorType.TEN_THOUSAND).build()); + assertThat(tenThousand.numerator()).isEqualTo(2); + assertThat(tenThousand.denominator()).isEqualTo(10_000); + + Matchers.FractionMatcher million = MatcherParser.parseFractionMatcher(FractionalPercent + .newBuilder().setNumerator(3).setDenominator(DenominatorType.MILLION).build()); + assertThat(million.numerator()).isEqualTo(3); + assertThat(million.denominator()).isEqualTo(1_000_000); + } + + @Test + public void parseFractionMatcher_unknownDenominatorThrows() { + FractionalPercent unknownProto = + FractionalPercent.newBuilder().setDenominatorValue(999).build(); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> MatcherParser.parseFractionMatcher(unknownProto)); + assertThat(exception).hasMessageThat().contains("Unknown denominator type"); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/XdsHeaderValidatorTest.java b/xds/src/test/java/io/grpc/xds/internal/XdsHeaderValidatorTest.java new file mode 100644 index 00000000000..c6c99c6d46f --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/XdsHeaderValidatorTest.java @@ -0,0 +1,64 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.base.Strings; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class XdsHeaderValidatorTest { + + @Test + public void isValid_validKeyAndLength_returnsTrue() { + assertThat(XdsHeaderValidator.isValid("valid-key", 10)).isTrue(); + } + + @Test + public void isValid_emptyKey_returnsFalse() { + assertThat(XdsHeaderValidator.isValid("", 10)).isFalse(); + } + + @Test + public void isValid_uppercaseKey_returnsFalse() { + assertThat(XdsHeaderValidator.isValid("Invalid-Key", 10)).isFalse(); + } + + @Test + public void isValid_keyExceedsMaxLength_returnsFalse() { + String longKey = Strings.repeat("k", 16385); + assertThat(XdsHeaderValidator.isValid(longKey, 10)).isFalse(); + } + + @Test + public void isValid_valueExceedsMaxLength_returnsFalse() { + assertThat(XdsHeaderValidator.isValid("valid-key", 16385)).isFalse(); + } + + @Test + public void isValid_hostKey_returnsFalse() { + assertThat(XdsHeaderValidator.isValid("host", 10)).isFalse(); + } + + @Test + public void isValid_pseudoHeaderKey_returnsFalse() { + assertThat(XdsHeaderValidator.isValid(":method", 10)).isFalse(); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzConfigTest.java b/xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzConfigParserTest.java similarity index 63% rename from xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzConfigTest.java rename to xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzConfigParserTest.java index 9b9a55b4079..373ad98552d 100644 --- a/xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzConfigTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzConfigParserTest.java @@ -42,12 +42,12 @@ import org.junit.runners.JUnit4; @RunWith(JUnit4.class) -public class ExtAuthzConfigTest { +public class ExtAuthzConfigParserTest { private static final Any GOOGLE_DEFAULT_CHANNEL_CREDS = Any.pack(GoogleDefaultCredentials.newBuilder().build()); private static final Any FAKE_ACCESS_TOKEN_CALL_CREDS = - Any.pack(AccessTokenCredentials.newBuilder().build()); + Any.pack(AccessTokenCredentials.newBuilder().setToken("fake-token").build()); private ExtAuthz.Builder extAuthzBuilder; @@ -63,10 +63,11 @@ public void setUp() { } @Test - public void fromProto_missingGrpcService_throws() { + public void parse_missingGrpcService_throws() { ExtAuthz extAuthz = ExtAuthz.newBuilder().build(); try { - ExtAuthzConfig.fromProto(extAuthz); + ExtAuthzConfigParser.parse(extAuthz, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil.dummyProvider()); fail("Expected ExtAuthzParseException"); } catch (ExtAuthzParseException e) { assertThat(e).hasMessageThat() @@ -75,12 +76,13 @@ public void fromProto_missingGrpcService_throws() { } @Test - public void fromProto_invalidGrpcService_throws() { + public void parse_invalidGrpcService_throws() { ExtAuthz extAuthz = ExtAuthz.newBuilder() .setGrpcService(io.envoyproxy.envoy.config.core.v3.GrpcService.newBuilder().build()) .build(); try { - ExtAuthzConfig.fromProto(extAuthz); + ExtAuthzConfigParser.parse(extAuthz, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil.dummyProvider()); fail("Expected ExtAuthzParseException"); } catch (ExtAuthzParseException e) { assertThat(e).hasMessageThat().startsWith("Failed to parse GrpcService config:"); @@ -88,13 +90,14 @@ public void fromProto_invalidGrpcService_throws() { } @Test - public void fromProto_invalidAllowExpression_throws() { + public void parse_invalidAllowExpression_throws() { ExtAuthz extAuthz = extAuthzBuilder .setDecoderHeaderMutationRules(HeaderMutationRules.newBuilder() .setAllowExpression(RegexMatcher.newBuilder().setRegex("[invalid").build()).build()) .build(); try { - ExtAuthzConfig.fromProto(extAuthz); + ExtAuthzConfigParser.parse(extAuthz, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil.dummyProvider()); fail("Expected ExtAuthzParseException"); } catch (ExtAuthzParseException e) { assertThat(e).hasMessageThat().startsWith("Invalid regex pattern for allow_expression:"); @@ -102,13 +105,14 @@ public void fromProto_invalidAllowExpression_throws() { } @Test - public void fromProto_invalidDisallowExpression_throws() { + public void parse_invalidDisallowExpression_throws() { ExtAuthz extAuthz = extAuthzBuilder .setDecoderHeaderMutationRules(HeaderMutationRules.newBuilder() .setDisallowExpression(RegexMatcher.newBuilder().setRegex("[invalid").build()).build()) .build(); try { - ExtAuthzConfig.fromProto(extAuthz); + ExtAuthzConfigParser.parse(extAuthz, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil.dummyProvider()); fail("Expected ExtAuthzParseException"); } catch (ExtAuthzParseException e) { assertThat(e).hasMessageThat().startsWith("Invalid regex pattern for disallow_expression:"); @@ -116,37 +120,40 @@ public void fromProto_invalidDisallowExpression_throws() { } @Test - public void fromProto_success() throws ExtAuthzParseException { - ExtAuthz extAuthz = extAuthzBuilder - .setGrpcService(extAuthzBuilder.getGrpcServiceBuilder() - .setTimeout(com.google.protobuf.Duration.newBuilder().setSeconds(5).build()) - .addInitialMetadata(HeaderValue.newBuilder().setKey("key").setValue("value").build()) - .build()) - .setFailureModeAllow(true).setFailureModeAllowHeaderAdd(true) - .setIncludePeerCertificate(true) - .setStatusOnError( - io.envoyproxy.envoy.type.v3.HttpStatus.newBuilder().setCodeValue(403).build()) - .setDenyAtDisable( - RuntimeFeatureFlag.newBuilder().setDefaultValue(BoolValue.of(true)).build()) - .setFilterEnabled(RuntimeFractionalPercent.newBuilder() - .setDefaultValue(FractionalPercent.newBuilder().setNumerator(50) - .setDenominator(DenominatorType.TEN_THOUSAND).build()) - .build()) - .setAllowedHeaders(ListStringMatcher.newBuilder() - .addPatterns(StringMatcher.newBuilder().setExact("allowed-header").build()).build()) - .setDisallowedHeaders(ListStringMatcher.newBuilder() - .addPatterns(StringMatcher.newBuilder().setPrefix("disallowed-").build()).build()) - .setDecoderHeaderMutationRules(HeaderMutationRules.newBuilder() - .setAllowExpression(RegexMatcher.newBuilder().setRegex("allow.*").build()) - .setDisallowExpression(RegexMatcher.newBuilder().setRegex("disallow.*").build()) - .setDisallowAll(BoolValue.of(true)).setDisallowIsError(BoolValue.of(true)).build()) - .build(); + public void parse_success() throws ExtAuthzParseException { + ExtAuthz extAuthz = + extAuthzBuilder + .setGrpcService(extAuthzBuilder.getGrpcServiceBuilder() + .setTimeout(com.google.protobuf.Duration.newBuilder().setSeconds(5).build()) + .addInitialMetadata( + HeaderValue.newBuilder().setKey("key").setValue("value").build()) + .build()) + .setFailureModeAllow(true).setFailureModeAllowHeaderAdd(true) + .setIncludePeerCertificate(true) + .setStatusOnError( + io.envoyproxy.envoy.type.v3.HttpStatus.newBuilder().setCodeValue(403).build()) + .setDenyAtDisable( + RuntimeFeatureFlag.newBuilder().setDefaultValue(BoolValue.of(true)).build()) + .setFilterEnabled(RuntimeFractionalPercent.newBuilder() + .setDefaultValue(FractionalPercent.newBuilder().setNumerator(50) + .setDenominator(DenominatorType.TEN_THOUSAND).build()) + .build()) + .setAllowedHeaders(ListStringMatcher.newBuilder() + .addPatterns(StringMatcher.newBuilder().setExact("allowed-header").build()).build()) + .setDisallowedHeaders(ListStringMatcher.newBuilder() + .addPatterns(StringMatcher.newBuilder().setPrefix("disallowed-").build()).build()) + .setDecoderHeaderMutationRules(HeaderMutationRules.newBuilder() + .setAllowExpression(RegexMatcher.newBuilder().setRegex("allow.*").build()) + .setDisallowExpression(RegexMatcher.newBuilder().setRegex("disallow.*").build()) + .setDisallowAll(BoolValue.of(true)).setDisallowIsError(BoolValue.of(true)).build()) + .build(); - ExtAuthzConfig config = ExtAuthzConfig.fromProto(extAuthz); + ExtAuthzConfig config = ExtAuthzConfigParser.parse(extAuthz, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil.dummyProvider()); assertThat(config.grpcService().googleGrpc().target()).isEqualTo("test-cluster"); assertThat(config.grpcService().timeout().get().getSeconds()).isEqualTo(5); - assertThat(config.grpcService().initialMetadata().isPresent()).isTrue(); + assertThat(config.grpcService().initialMetadata()).isNotEmpty(); assertThat(config.failureModeAllow()).isTrue(); assertThat(config.failureModeAllowHeaderAdd()).isTrue(); assertThat(config.includePeerCertificate()).isTrue(); @@ -167,10 +174,11 @@ public void fromProto_success() throws ExtAuthzParseException { } @Test - public void fromProto_saneDefaults() throws ExtAuthzParseException { + public void parse_saneDefaults() throws ExtAuthzParseException { ExtAuthz extAuthz = extAuthzBuilder.build(); - ExtAuthzConfig config = ExtAuthzConfig.fromProto(extAuthz); + ExtAuthzConfig config = ExtAuthzConfigParser.parse(extAuthz, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil.dummyProvider()); assertThat(config.failureModeAllow()).isFalse(); assertThat(config.failureModeAllowHeaderAdd()).isFalse(); @@ -184,13 +192,14 @@ public void fromProto_saneDefaults() throws ExtAuthzParseException { } @Test - public void fromProto_headerMutationRules_allowExpressionOnly() throws ExtAuthzParseException { + public void parse_headerMutationRules_allowExpressionOnly() throws ExtAuthzParseException { ExtAuthz extAuthz = extAuthzBuilder .setDecoderHeaderMutationRules(HeaderMutationRules.newBuilder() .setAllowExpression(RegexMatcher.newBuilder().setRegex("allow.*").build()).build()) .build(); - ExtAuthzConfig config = ExtAuthzConfig.fromProto(extAuthz); + ExtAuthzConfig config = ExtAuthzConfigParser.parse(extAuthz, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil.dummyProvider()); assertThat(config.decoderHeaderMutationRules().isPresent()).isTrue(); HeaderMutationRulesConfig rules = config.decoderHeaderMutationRules().get(); @@ -199,14 +208,14 @@ public void fromProto_headerMutationRules_allowExpressionOnly() throws ExtAuthzP } @Test - public void fromProto_headerMutationRules_disallowExpressionOnly() throws ExtAuthzParseException { - ExtAuthz extAuthz = extAuthzBuilder - .setDecoderHeaderMutationRules(HeaderMutationRules.newBuilder() + public void parse_headerMutationRules_disallowExpressionOnly() throws ExtAuthzParseException { + ExtAuthz extAuthz = + extAuthzBuilder.setDecoderHeaderMutationRules(HeaderMutationRules.newBuilder() .setDisallowExpression(RegexMatcher.newBuilder().setRegex("disallow.*").build()) - .build()) - .build(); + .build()).build(); - ExtAuthzConfig config = ExtAuthzConfig.fromProto(extAuthz); + ExtAuthzConfig config = ExtAuthzConfigParser.parse(extAuthz, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil.dummyProvider()); assertThat(config.decoderHeaderMutationRules().isPresent()).isTrue(); HeaderMutationRulesConfig rules = config.decoderHeaderMutationRules().get(); @@ -215,45 +224,46 @@ public void fromProto_headerMutationRules_disallowExpressionOnly() throws ExtAut } @Test - public void fromProto_filterEnabled_hundred() throws ExtAuthzParseException { + public void parse_filterEnabled_hundred() throws ExtAuthzParseException { ExtAuthz extAuthz = extAuthzBuilder .setFilterEnabled(RuntimeFractionalPercent.newBuilder().setDefaultValue(FractionalPercent .newBuilder().setNumerator(25).setDenominator(DenominatorType.HUNDRED).build()).build()) .build(); - ExtAuthzConfig config = ExtAuthzConfig.fromProto(extAuthz); + ExtAuthzConfig config = ExtAuthzConfigParser.parse(extAuthz, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil.dummyProvider()); assertThat(config.filterEnabled()).isEqualTo(Matchers.FractionMatcher.create(25, 100)); } @Test - public void fromProto_filterEnabled_million() throws ExtAuthzParseException { + public void parse_filterEnabled_million() throws ExtAuthzParseException { ExtAuthz extAuthz = extAuthzBuilder .setFilterEnabled( RuntimeFractionalPercent.newBuilder().setDefaultValue(FractionalPercent.newBuilder() .setNumerator(123456).setDenominator(DenominatorType.MILLION).build()).build()) .build(); - ExtAuthzConfig config = ExtAuthzConfig.fromProto(extAuthz); + ExtAuthzConfig config = ExtAuthzConfigParser.parse(extAuthz, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil.dummyProvider()); assertThat(config.filterEnabled()) .isEqualTo(Matchers.FractionMatcher.create(123456, 1_000_000)); } @Test - public void fromProto_filterEnabled_unrecognizedDenominator() { - ExtAuthz extAuthz = extAuthzBuilder - .setFilterEnabled(RuntimeFractionalPercent.newBuilder() - .setDefaultValue( - FractionalPercent.newBuilder().setNumerator(1).setDenominatorValue(4).build()) - .build()) - .build(); + public void parse_filterEnabled_unrecognizedDenominator() { + ExtAuthz extAuthz = extAuthzBuilder.setFilterEnabled(RuntimeFractionalPercent.newBuilder() + .setDefaultValue( + FractionalPercent.newBuilder().setNumerator(1).setDenominatorValue(4).build()) + .build()).build(); try { - ExtAuthzConfig.fromProto(extAuthz); + ExtAuthzConfigParser.parse(extAuthz, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil.dummyProvider()); fail("Expected ExtAuthzParseException"); } catch (ExtAuthzParseException e) { assertThat(e).hasMessageThat().isEqualTo("Unknown denominator type: UNRECOGNIZED"); } } -} \ No newline at end of file +} diff --git a/xds/src/test/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigParserTest.java b/xds/src/test/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigParserTest.java new file mode 100644 index 00000000000..1a7634aadf7 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigParserTest.java @@ -0,0 +1,390 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.grpcservice; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.protobuf.Any; +import com.google.protobuf.Duration; +import io.envoyproxy.envoy.config.core.v3.GrpcService; +import io.envoyproxy.envoy.config.core.v3.HeaderValue; +import io.envoyproxy.envoy.extensions.grpc_service.call_credentials.access_token.v3.AccessTokenCredentials; +import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.google_default.v3.GoogleDefaultCredentials; +import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.insecure.v3.InsecureCredentials; +import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.local.v3.LocalCredentials; +import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.xds.v3.XdsCredentials; +import io.grpc.InsecureChannelCredentials; +import io.grpc.xds.internal.grpcservice.GrpcServiceXdsContext.AllowedGrpcService; +import java.nio.charset.StandardCharsets; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class GrpcServiceConfigParserTest { + + private static final String CALL_CREDENTIALS_CLASS_NAME = + "io.grpc.xds.internal.grpcservice.GrpcServiceConfigParser" + + "$SecurityAwareAccessTokenCredentials"; + + @Test + public void parse_success() throws GrpcServiceParseException { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).addCallCredentialsPlugin(accessTokenCreds) + .build(); + HeaderValue asciiHeader = + HeaderValue.newBuilder().setKey("test_key").setValue("test_value").build(); + HeaderValue binaryHeader = + HeaderValue.newBuilder().setKey("test_key-bin").setRawValue(com.google.protobuf.ByteString + .copyFrom("test_value_binary".getBytes(StandardCharsets.UTF_8))).build(); + Duration timeout = Duration.newBuilder().setSeconds(10).build(); + GrpcService grpcService = + GrpcService.newBuilder().setGoogleGrpc(googleGrpc).addInitialMetadata(asciiHeader) + .addInitialMetadata(binaryHeader).setTimeout(timeout).build(); + + GrpcServiceConfig config = GrpcServiceConfigParser.parse(grpcService, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil.dummyProvider()); + + // Assert target URI + assertThat(config.googleGrpc().target()).isEqualTo("test_uri"); + + // Assert channel credentials + assertThat(config.googleGrpc().configuredChannelCredentials().channelCredentials()) + .isInstanceOf(InsecureChannelCredentials.class); + GrpcServiceConfigParser.ProtoChannelCredsConfig credsConfig = + (GrpcServiceConfigParser.ProtoChannelCredsConfig) + config.googleGrpc().configuredChannelCredentials().channelCredsConfig(); + assertThat(credsConfig.configProto()).isEqualTo(insecureCreds); + + // Assert call credentials + assertThat(config.googleGrpc().callCredentials().isPresent()).isTrue(); + assertThat(config.googleGrpc().callCredentials().get().getClass().getName()) + .isEqualTo(CALL_CREDENTIALS_CLASS_NAME); + + // Assert initial metadata + assertThat(config.initialMetadata()).isNotEmpty(); + assertThat(config.initialMetadata().get(0).key()).isEqualTo("test_key"); + assertThat(config.initialMetadata().get(0).value().get()).isEqualTo("test_value"); + assertThat(config.initialMetadata().get(1).key()).isEqualTo("test_key-bin"); + assertThat(config.initialMetadata().get(1).rawValue().get().toByteArray()) + .isEqualTo("test_value_binary".getBytes(StandardCharsets.UTF_8)); + + // Assert timeout + assertThat(config.timeout().isPresent()).isTrue(); + assertThat(config.timeout().get()).isEqualTo(java.time.Duration.ofSeconds(10)); + } + + @Test + public void parse_minimalSuccess_defaults() throws GrpcServiceParseException { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).addCallCredentialsPlugin(accessTokenCreds) + .build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceConfig config = GrpcServiceConfigParser.parse(grpcService, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil.dummyProvider()); + + assertThat(config.googleGrpc().target()).isEqualTo("test_uri"); + assertThat(config.initialMetadata()).isEmpty(); + assertThat(config.timeout().isPresent()).isFalse(); + } + + @Test + public void parse_missingGoogleGrpc() { + GrpcService grpcService = GrpcService.newBuilder().build(); + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> GrpcServiceConfigParser.parse(grpcService, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil + .dummyProvider())); + assertThat(exception).hasMessageThat() + .startsWith("Unsupported: GrpcService must have GoogleGrpc, got: "); + } + + @Test + public void parse_emptyCallCredentials() throws GrpcServiceParseException { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceConfig config = GrpcServiceConfigParser.parse(grpcService, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil.dummyProvider()); + + assertThat(config.googleGrpc().callCredentials().isPresent()).isFalse(); + } + + @Test + public void parse_emptyChannelCredentials() { + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addCallCredentialsPlugin(accessTokenCreds).build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> GrpcServiceConfigParser.parse(grpcService, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil + .dummyProvider())); + assertThat(exception).hasMessageThat() + .isEqualTo("No valid supported channel_credentials found"); + } + + @Test + public void parse_googleDefaultCredentials() throws GrpcServiceParseException { + Any googleDefaultCreds = Any.pack(GoogleDefaultCredentials.getDefaultInstance()); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(googleDefaultCreds).addCallCredentialsPlugin(accessTokenCreds) + .build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceConfig config = GrpcServiceConfigParser.parse(grpcService, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil.dummyProvider()); + + assertThat(config.googleGrpc().configuredChannelCredentials().channelCredentials()) + .isInstanceOf(io.grpc.CompositeChannelCredentials.class); + GrpcServiceConfigParser.ProtoChannelCredsConfig credsConfig = + (GrpcServiceConfigParser.ProtoChannelCredsConfig) + config.googleGrpc().configuredChannelCredentials().channelCredsConfig(); + assertThat(credsConfig.configProto()).isEqualTo(googleDefaultCreds); + } + + @Test + public void parse_localCredentials() throws GrpcServiceParseException { + Any localCreds = Any.pack(LocalCredentials.getDefaultInstance()); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(localCreds).addCallCredentialsPlugin(accessTokenCreds).build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + UnsupportedOperationException exception = assertThrows(UnsupportedOperationException.class, + () -> GrpcServiceConfigParser.parse(grpcService, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil + .dummyProvider())); + assertThat(exception).hasMessageThat() + .contains("LocalCredentials are not supported in grpc-java"); + } + + @Test + public void parse_xdsCredentials_withInsecureFallback() throws GrpcServiceParseException { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + XdsCredentials xdsCreds = + XdsCredentials.newBuilder().setFallbackCredentials(insecureCreds).build(); + Any xdsCredsAny = Any.pack(xdsCreds); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(xdsCredsAny).addCallCredentialsPlugin(accessTokenCreds) + .build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceConfig config = GrpcServiceConfigParser.parse(grpcService, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil.dummyProvider()); + + assertThat(config.googleGrpc().configuredChannelCredentials().channelCredentials()) + .isInstanceOf(io.grpc.ChannelCredentials.class); + GrpcServiceConfigParser.ProtoChannelCredsConfig credsConfig = + (GrpcServiceConfigParser.ProtoChannelCredsConfig) + config.googleGrpc().configuredChannelCredentials().channelCredsConfig(); + assertThat(credsConfig.configProto()).isEqualTo(xdsCredsAny); + } + + @Test + public void parse_tlsCredentials_notSupported() { + Any tlsCreds = Any + .pack(io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.tls.v3.TlsCredentials + .getDefaultInstance()); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(tlsCreds).addCallCredentialsPlugin(accessTokenCreds).build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + UnsupportedOperationException exception = assertThrows(UnsupportedOperationException.class, + () -> GrpcServiceConfigParser.parse(grpcService, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil + .dummyProvider())); + assertThat(exception).hasMessageThat() + .contains("TlsCredentials input stream construction pending"); + } + + @Test + public void parse_invalidChannelCredentialsProto() { + // Pack a Duration proto, but try to unpack it as GoogleDefaultCredentials + Any invalidCreds = Any.pack(com.google.protobuf.Duration.getDefaultInstance()); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(invalidCreds).addCallCredentialsPlugin(accessTokenCreds) + .build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> GrpcServiceConfigParser.parse(grpcService, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil + .dummyProvider())); + assertThat(exception).hasMessageThat().contains("No valid supported channel_credentials found"); + } + + @Test + public void parse_ignoredUnsupportedCallCredentialsProto() throws GrpcServiceParseException { + // Pack a Duration proto, but try to unpack it as AccessTokenCredentials + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + Any invalidCallCredentials = Any.pack(Duration.getDefaultInstance()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).addCallCredentialsPlugin(invalidCallCredentials) + .build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceConfig config = GrpcServiceConfigParser.parse(grpcService, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil.dummyProvider()); + assertThat(config.googleGrpc().callCredentials().isPresent()).isFalse(); + } + + @Test + public void parse_invalidAccessTokenCallCredentialsProto() { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + Any invalidCallCredentials = Any.pack(AccessTokenCredentials.newBuilder().setToken("").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).addCallCredentialsPlugin(invalidCallCredentials) + .build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> GrpcServiceConfigParser.parse(grpcService, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil + .dummyProvider())); + assertThat(exception).hasMessageThat() + .contains("Missing or empty access token in call credentials"); + } + + @Test + public void parse_multipleCallCredentials() throws GrpcServiceParseException { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + Any accessTokenCreds1 = + Any.pack(AccessTokenCredentials.newBuilder().setToken("token1").build()); + Any accessTokenCreds2 = + Any.pack(AccessTokenCredentials.newBuilder().setToken("token2").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).addCallCredentialsPlugin(accessTokenCreds1) + .addCallCredentialsPlugin(accessTokenCreds2).build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceConfig config = GrpcServiceConfigParser.parse(grpcService, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil.dummyProvider()); + + assertThat(config.googleGrpc().callCredentials().isPresent()).isTrue(); + assertThat(config.googleGrpc().callCredentials().get()) + .isInstanceOf(io.grpc.CompositeCallCredentials.class); + } + + @Test + public void parse_untrustedControlPlane_withoutOverride() { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceXdsContext untrustedContext = + GrpcServiceXdsContext.create(false, java.util.Optional.empty(), true); + + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> GrpcServiceConfigParser.parse(grpcService, targetUri -> untrustedContext)); + assertThat(exception).hasMessageThat() + .contains("Untrusted xDS server & URI not found in allowed_grpc_services"); + } + + @Test + public void parse_untrustedControlPlane_withOverride() throws GrpcServiceParseException { + // The proto credentials (insecure) should be ignored in favor of the override (google default) + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + ConfiguredChannelCredentials overrideChannelCreds = ConfiguredChannelCredentials.create( + io.grpc.alts.GoogleDefaultChannelCredentials.create(), + new GrpcServiceConfigParser.ProtoChannelCredsConfig( + GrpcServiceConfigParser.GOOGLE_DEFAULT_CREDENTIALS_TYPE_URL, + Any.pack(GoogleDefaultCredentials.getDefaultInstance()))); + AllowedGrpcService override = AllowedGrpcService.builder() + .configuredChannelCredentials(overrideChannelCreds).build(); + + GrpcServiceXdsContext untrustedContext = + GrpcServiceXdsContext.create(false, java.util.Optional.of(override), true); + + GrpcServiceConfig config = + GrpcServiceConfigParser.parse(grpcService, targetUri -> untrustedContext); + + // Assert channel credentials are the override, not the proto's insecure creds + assertThat(config.googleGrpc().configuredChannelCredentials().channelCredentials()) + .isInstanceOf(io.grpc.CompositeChannelCredentials.class); + } + + @Test + public void parse_invalidTimeout() { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).build(); + + // Negative timeout + Duration timeout = Duration.newBuilder().setSeconds(-10).build(); + GrpcService grpcService = GrpcService.newBuilder() + .setGoogleGrpc(googleGrpc).setTimeout(timeout).build(); + + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> GrpcServiceConfigParser.parse(grpcService, + GrpcServiceXdsContextTestUtil.dummyProvider())); + assertThat(exception).hasMessageThat() + .contains("Timeout must be strictly positive"); + + // Zero timeout + timeout = Duration.newBuilder().setSeconds(0).setNanos(0).build(); + GrpcService grpcServiceZero = GrpcService.newBuilder() + .setGoogleGrpc(googleGrpc).setTimeout(timeout).build(); + + exception = assertThrows(GrpcServiceParseException.class, + () -> GrpcServiceConfigParser.parse(grpcServiceZero, + GrpcServiceXdsContextTestUtil.dummyProvider())); + assertThat(exception).hasMessageThat() + .contains("Timeout must be strictly positive"); + } + + @Test + public void parseGoogleGrpcConfig_unsupportedScheme() { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("unknown://test") + .addChannelCredentialsPlugin(insecureCreds).build(); + + GrpcServiceXdsContext context = + GrpcServiceXdsContext.create(true, java.util.Optional.empty(), false); + + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> GrpcServiceConfigParser.parseGoogleGrpcConfig(googleGrpc, targetUri -> context)); + assertThat(exception).hasMessageThat() + .contains("Target URI scheme is not resolvable"); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigTest.java b/xds/src/test/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigTest.java deleted file mode 100644 index 7a506220973..00000000000 --- a/xds/src/test/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigTest.java +++ /dev/null @@ -1,243 +0,0 @@ -/* - * Copyright 2025 The gRPC Authors - * - * Licensed 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 io.grpc.xds.internal.grpcservice; - -import static com.google.common.truth.Truth.assertThat; -import static org.junit.Assert.assertThrows; - -import com.google.common.io.BaseEncoding; -import com.google.protobuf.Any; -import com.google.protobuf.Duration; -import io.envoyproxy.envoy.config.core.v3.GrpcService; -import io.envoyproxy.envoy.config.core.v3.HeaderValue; -import io.envoyproxy.envoy.extensions.grpc_service.call_credentials.access_token.v3.AccessTokenCredentials; -import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.google_default.v3.GoogleDefaultCredentials; -import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.insecure.v3.InsecureCredentials; -import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.local.v3.LocalCredentials; -import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.xds.v3.XdsCredentials; -import io.grpc.InsecureChannelCredentials; -import io.grpc.Metadata; -import java.nio.charset.StandardCharsets; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; - -@RunWith(JUnit4.class) -public class GrpcServiceConfigTest { - - @Test - public void fromProto_success() throws GrpcServiceParseException { - Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); - Any accessTokenCreds = - Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); - GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") - .addChannelCredentialsPlugin(insecureCreds).addCallCredentialsPlugin(accessTokenCreds) - .build(); - HeaderValue asciiHeader = - HeaderValue.newBuilder().setKey("test_key").setValue("test_value").build(); - HeaderValue binaryHeader = HeaderValue.newBuilder().setKey("test_key-bin") - .setValue( - BaseEncoding.base64().encode("test_value_binary".getBytes(StandardCharsets.UTF_8))) - .build(); - Duration timeout = Duration.newBuilder().setSeconds(10).build(); - GrpcService grpcService = - GrpcService.newBuilder().setGoogleGrpc(googleGrpc).addInitialMetadata(asciiHeader) - .addInitialMetadata(binaryHeader).setTimeout(timeout).build(); - - GrpcServiceConfig config = GrpcServiceConfig.fromProto(grpcService); - - // Assert target URI - assertThat(config.googleGrpc().target()).isEqualTo("test_uri"); - - // Assert channel credentials - assertThat(config.googleGrpc().hashedChannelCredentials().channelCredentials()) - .isInstanceOf(InsecureChannelCredentials.class); - assertThat(config.googleGrpc().hashedChannelCredentials().hash()) - .isEqualTo(insecureCreds.hashCode()); - - // Assert call credentials - assertThat(config.googleGrpc().callCredentials().getClass().getName()) - .isEqualTo("io.grpc.auth.GoogleAuthLibraryCallCredentials"); - - // Assert initial metadata - assertThat(config.initialMetadata().isPresent()).isTrue(); - assertThat(config.initialMetadata().get() - .get(Metadata.Key.of("test_key", Metadata.ASCII_STRING_MARSHALLER))) - .isEqualTo("test_value"); - assertThat(config.initialMetadata().get() - .get(Metadata.Key.of("test_key-bin", Metadata.BINARY_BYTE_MARSHALLER))) - .isEqualTo("test_value_binary".getBytes(StandardCharsets.UTF_8)); - - // Assert timeout - assertThat(config.timeout().isPresent()).isTrue(); - assertThat(config.timeout().get()).isEqualTo(java.time.Duration.ofSeconds(10)); - } - - @Test - public void fromProto_minimalSuccess_defaults() throws GrpcServiceParseException { - Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); - Any accessTokenCreds = - Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); - GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") - .addChannelCredentialsPlugin(insecureCreds).addCallCredentialsPlugin(accessTokenCreds) - .build(); - GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); - - GrpcServiceConfig config = GrpcServiceConfig.fromProto(grpcService); - - assertThat(config.googleGrpc().target()).isEqualTo("test_uri"); - assertThat(config.initialMetadata().isPresent()).isFalse(); - assertThat(config.timeout().isPresent()).isFalse(); - } - - @Test - public void fromProto_missingGoogleGrpc() { - GrpcService grpcService = GrpcService.newBuilder().build(); - GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, - () -> GrpcServiceConfig.fromProto(grpcService)); - assertThat(exception).hasMessageThat() - .startsWith("Unsupported: GrpcService must have GoogleGrpc, got: "); - } - - @Test - public void fromProto_emptyCallCredentials() { - Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); - GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") - .addChannelCredentialsPlugin(insecureCreds).build(); - GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); - GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, - () -> GrpcServiceConfig.fromProto(grpcService)); - assertThat(exception).hasMessageThat() - .isEqualTo("No valid supported call_credentials found. Errors: []"); - } - - @Test - public void fromProto_emptyChannelCredentials() { - Any accessTokenCreds = - Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); - GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") - .addCallCredentialsPlugin(accessTokenCreds).build(); - GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); - GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, - () -> GrpcServiceConfig.fromProto(grpcService)); - assertThat(exception).hasMessageThat() - .isEqualTo("No valid supported channel_credentials found. Errors: []"); - } - - @Test - public void fromProto_googleDefaultCredentials() throws GrpcServiceParseException { - Any googleDefaultCreds = Any.pack(GoogleDefaultCredentials.getDefaultInstance()); - Any accessTokenCreds = - Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); - GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") - .addChannelCredentialsPlugin(googleDefaultCreds).addCallCredentialsPlugin(accessTokenCreds) - .build(); - GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); - - GrpcServiceConfig config = GrpcServiceConfig.fromProto(grpcService); - - assertThat(config.googleGrpc().hashedChannelCredentials().channelCredentials()) - .isInstanceOf(io.grpc.CompositeChannelCredentials.class); - assertThat(config.googleGrpc().hashedChannelCredentials().hash()) - .isEqualTo(googleDefaultCreds.hashCode()); - } - - @Test - public void fromProto_localCredentials() throws GrpcServiceParseException { - Any localCreds = Any.pack(LocalCredentials.getDefaultInstance()); - Any accessTokenCreds = - Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); - GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") - .addChannelCredentialsPlugin(localCreds).addCallCredentialsPlugin(accessTokenCreds).build(); - GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); - - GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, - () -> GrpcServiceConfig.fromProto(grpcService)); - assertThat(exception).hasMessageThat().contains("LocalCredentials are not yet supported."); - } - - @Test - public void fromProto_xdsCredentials_withInsecureFallback() throws GrpcServiceParseException { - Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); - XdsCredentials xdsCreds = - XdsCredentials.newBuilder().setFallbackCredentials(insecureCreds).build(); - Any xdsCredsAny = Any.pack(xdsCreds); - Any accessTokenCreds = - Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); - GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") - .addChannelCredentialsPlugin(xdsCredsAny).addCallCredentialsPlugin(accessTokenCreds) - .build(); - GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); - - GrpcServiceConfig config = GrpcServiceConfig.fromProto(grpcService); - - assertThat(config.googleGrpc().hashedChannelCredentials().channelCredentials()) - .isInstanceOf(io.grpc.ChannelCredentials.class); - assertThat(config.googleGrpc().hashedChannelCredentials().hash()) - .isEqualTo(xdsCredsAny.hashCode()); - } - - @Test - public void fromProto_tlsCredentials_notSupported() { - Any tlsCreds = Any - .pack(io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.tls.v3.TlsCredentials - .getDefaultInstance()); - Any accessTokenCreds = - Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); - GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") - .addChannelCredentialsPlugin(tlsCreds).addCallCredentialsPlugin(accessTokenCreds).build(); - GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); - - GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, - () -> GrpcServiceConfig.fromProto(grpcService)); - assertThat(exception).hasMessageThat().contains("TlsCredentials are not yet supported."); - } - - @Test - public void fromProto_invalidChannelCredentialsProto() { - // Pack a Duration proto, but try to unpack it as GoogleDefaultCredentials - Any invalidCreds = Any.pack(com.google.protobuf.Duration.getDefaultInstance()); - Any accessTokenCreds = - Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); - GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") - .addChannelCredentialsPlugin(invalidCreds).addCallCredentialsPlugin(accessTokenCreds) - .build(); - GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); - - GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, - () -> GrpcServiceConfig.fromProto(grpcService)); - assertThat(exception).hasMessageThat() - .contains("No valid supported channel_credentials found. Errors: [Unsupported channel " - + "credentials type: type.googleapis.com/google.protobuf.Duration"); - } - - @Test - public void fromProto_invalidCallCredentialsProto() { - // Pack a Duration proto, but try to unpack it as AccessTokenCredentials - Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); - Any invalidCallCredentials = Any.pack(Duration.getDefaultInstance()); - GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") - .addChannelCredentialsPlugin(insecureCreds).addCallCredentialsPlugin(invalidCallCredentials) - .build(); - GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); - - GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, - () -> GrpcServiceConfig.fromProto(grpcService)); - assertThat(exception).hasMessageThat().contains("Unsupported call credentials type:"); - } -} - diff --git a/xds/src/test/java/io/grpc/xds/internal/grpcservice/GrpcServiceXdsContextTestUtil.java b/xds/src/test/java/io/grpc/xds/internal/grpcservice/GrpcServiceXdsContextTestUtil.java new file mode 100644 index 00000000000..efcbce0c8cf --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/grpcservice/GrpcServiceXdsContextTestUtil.java @@ -0,0 +1,30 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.grpcservice; + +import java.util.Optional; + +/** + * Utility for creating dummy contexts/providers in tests. + */ +public final class GrpcServiceXdsContextTestUtil { + private GrpcServiceXdsContextTestUtil() {} + + public static GrpcServiceXdsContextProvider dummyProvider() { + return targetUri -> GrpcServiceXdsContext.create(true, Optional.empty(), true); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/grpcservice/HeaderValueTest.java b/xds/src/test/java/io/grpc/xds/internal/grpcservice/HeaderValueTest.java new file mode 100644 index 00000000000..b55e6ae76f7 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/grpcservice/HeaderValueTest.java @@ -0,0 +1,49 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.grpcservice; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.protobuf.ByteString; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class HeaderValueTest { + + @Test + public void create_withStringValue_success() { + HeaderValue headerValue = HeaderValue.create("key1", "value1"); + assertThat(headerValue.key()).isEqualTo("key1"); + assertThat(headerValue.value().isPresent()).isTrue(); + assertThat(headerValue.value().get()).isEqualTo("value1"); + assertThat(headerValue.rawValue().isPresent()).isFalse(); + } + + @Test + public void create_withByteStringValue_success() { + ByteString rawValue = ByteString.copyFromUtf8("raw_value"); + HeaderValue headerValue = HeaderValue.create("key2", rawValue); + assertThat(headerValue.key()).isEqualTo("key2"); + assertThat(headerValue.rawValue().isPresent()).isTrue(); + assertThat(headerValue.rawValue().get()).isEqualTo(rawValue); + assertThat(headerValue.value().isPresent()).isFalse(); + } + + +} diff --git a/xds/src/test/java/io/grpc/xds/internal/grpcservice/InsecureGrpcChannelFactoryTest.java b/xds/src/test/java/io/grpc/xds/internal/grpcservice/InsecureGrpcChannelFactoryTest.java deleted file mode 100644 index 8d7347f56c6..00000000000 --- a/xds/src/test/java/io/grpc/xds/internal/grpcservice/InsecureGrpcChannelFactoryTest.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2025 The gRPC Authors - * - * Licensed 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 io.grpc.xds.internal.grpcservice; - -import static org.junit.Assert.assertNotNull; - -import io.grpc.CallCredentials; -import io.grpc.InsecureChannelCredentials; -import io.grpc.ManagedChannel; -import io.grpc.Metadata; -import io.grpc.xds.internal.grpcservice.GrpcServiceConfig.GoogleGrpcConfig; -import io.grpc.xds.internal.grpcservice.GrpcServiceConfig.HashedChannelCredentials; -import java.util.concurrent.Executor; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; - -/** Unit tests for {@link InsecureGrpcChannelFactory}. */ -@RunWith(JUnit4.class) -public class InsecureGrpcChannelFactoryTest { - - private static final class NoOpCallCredentials extends CallCredentials { - @Override - public void applyRequestMetadata(RequestInfo requestInfo, Executor appExecutor, - MetadataApplier applier) { - applier.apply(new Metadata()); - } - } - - @Test - public void testCreateChannel() { - InsecureGrpcChannelFactory factory = InsecureGrpcChannelFactory.getInstance(); - GrpcServiceConfig config = GrpcServiceConfig.builder() - .googleGrpc(GoogleGrpcConfig.builder().target("localhost:8080") - .hashedChannelCredentials( - HashedChannelCredentials.of(InsecureChannelCredentials.create(), 0)) - .callCredentials(new NoOpCallCredentials()).build()) - .build(); - ManagedChannel channel = factory.createChannel(config); - assertNotNull(channel); - channel.shutdownNow(); - } -} diff --git a/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfigTest.java b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfigTest.java index e2bda9cb836..9f5cb75460f 100644 --- a/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfigTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfigTest.java @@ -20,7 +20,7 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; -import java.util.regex.Pattern; +import com.google.re2j.Pattern; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; diff --git a/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesParserTest.java b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesParserTest.java new file mode 100644 index 00000000000..c572d5e80fc --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesParserTest.java @@ -0,0 +1,90 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.headermutations; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.protobuf.BoolValue; +import io.envoyproxy.envoy.config.common.mutation_rules.v3.HeaderMutationRules; +import io.envoyproxy.envoy.type.matcher.v3.RegexMatcher; +import io.grpc.xds.internal.extauthz.ExtAuthzParseException; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class HeaderMutationRulesParserTest { + + @Test + public void parse_protoWithAllFields_success() throws Exception { + HeaderMutationRules proto = HeaderMutationRules.newBuilder() + .setAllowExpression(RegexMatcher.newBuilder().setRegex("allow-.*")) + .setDisallowExpression(RegexMatcher.newBuilder().setRegex("disallow-.*")) + .setDisallowAll(BoolValue.newBuilder().setValue(true).build()) + .setDisallowIsError(BoolValue.newBuilder().setValue(true).build()) + .build(); + + HeaderMutationRulesConfig config = HeaderMutationRulesParser.parse(proto); + + assertThat(config.allowExpression().isPresent()).isTrue(); + assertThat(config.allowExpression().get().pattern()).isEqualTo("allow-.*"); + + assertThat(config.disallowExpression().isPresent()).isTrue(); + assertThat(config.disallowExpression().get().pattern()).isEqualTo("disallow-.*"); + + assertThat(config.disallowAll()).isTrue(); + assertThat(config.disallowIsError()).isTrue(); + } + + @Test + public void parse_protoWithNoExpressions_success() throws Exception { + HeaderMutationRules proto = HeaderMutationRules.newBuilder().build(); + + HeaderMutationRulesConfig config = HeaderMutationRulesParser.parse(proto); + + assertThat(config.allowExpression().isPresent()).isFalse(); + assertThat(config.disallowExpression().isPresent()).isFalse(); + assertThat(config.disallowAll()).isFalse(); + assertThat(config.disallowIsError()).isFalse(); + } + + @Test + public void parse_invalidRegexAllowExpression_throwsExtAuthzParseException() { + HeaderMutationRules proto = HeaderMutationRules.newBuilder() + .setAllowExpression(RegexMatcher.newBuilder().setRegex("allow-[")) + .build(); + + ExtAuthzParseException exception = assertThrows( + ExtAuthzParseException.class, () -> HeaderMutationRulesParser.parse(proto)); + + assertThat(exception).hasMessageThat().contains("Invalid regex pattern for allow_expression"); + } + + @Test + public void parse_invalidRegexDisallowExpression_throwsExtAuthzParseException() { + HeaderMutationRules proto = HeaderMutationRules.newBuilder() + .setDisallowExpression(RegexMatcher.newBuilder().setRegex("disallow-[")) + .build(); + + ExtAuthzParseException exception = assertThrows( + ExtAuthzParseException.class, () -> HeaderMutationRulesParser.parse(proto)); + + assertThat(exception).hasMessageThat() + .contains("Invalid regex pattern for disallow_expression"); + } +} From 5ed699362a475b76706269564fe593355ecd84f9 Mon Sep 17 00:00:00 2001 From: Saurav Date: Thu, 12 Mar 2026 13:59:19 +0000 Subject: [PATCH 3/6] Fixup: 12492 Split HeaderValueValidationUtils to GrpcService to match the updated requirements --- .../grpc/xds/internal/XdsHeaderValidator.java | 40 --------- .../grpcservice/GrpcServiceConfigParser.java | 16 ++-- .../HeaderValueValidationUtils.java | 67 ++++++++++++++ .../xds/internal/XdsHeaderValidatorTest.java | 64 -------------- .../HeaderValueValidationUtilsTest.java | 87 +++++++++++++++++++ 5 files changed, 161 insertions(+), 113 deletions(-) delete mode 100644 xds/src/main/java/io/grpc/xds/internal/XdsHeaderValidator.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/grpcservice/HeaderValueValidationUtils.java delete mode 100644 xds/src/test/java/io/grpc/xds/internal/XdsHeaderValidatorTest.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/grpcservice/HeaderValueValidationUtilsTest.java diff --git a/xds/src/main/java/io/grpc/xds/internal/XdsHeaderValidator.java b/xds/src/main/java/io/grpc/xds/internal/XdsHeaderValidator.java deleted file mode 100644 index dbd459b017b..00000000000 --- a/xds/src/main/java/io/grpc/xds/internal/XdsHeaderValidator.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2025 The gRPC Authors - * - * Licensed 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 io.grpc.xds.internal; - -/** - * Utility for validating header keys and values against xDS and Envoy specifications. - */ -public final class XdsHeaderValidator { - - private XdsHeaderValidator() {} - - /** - * Returns whether the header parameter is valid. The length to check is either the - * length of the string value or the size of the binary raw value. - */ - public static boolean isValid(String key, int valueLength) { - if (key.isEmpty() || !key.equals(key.toLowerCase(java.util.Locale.ROOT)) || key.length() > 16384 - || key.equals("host") || key.startsWith(":")) { - return false; - } - if (valueLength > 16384) { - return false; - } - return true; - } -} diff --git a/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigParser.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigParser.java index 7614484f396..a4616893ae4 100644 --- a/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigParser.java +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigParser.java @@ -33,7 +33,6 @@ import io.grpc.alts.GoogleDefaultChannelCredentials; import io.grpc.auth.MoreCallCredentials; import io.grpc.xds.XdsChannelCredentials; -import io.grpc.xds.internal.XdsHeaderValidator; import java.time.Duration; import java.util.ArrayList; import java.util.Date; @@ -88,17 +87,16 @@ public static GrpcServiceConfig parse(GrpcService grpcServiceProto, for (io.envoyproxy.envoy.config.core.v3.HeaderValue header : grpcServiceProto .getInitialMetadataList()) { String key = header.getKey(); + HeaderValue headerValue; if (key.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { - if (!XdsHeaderValidator.isValid(key, header.getRawValue().size())) { - throw new GrpcServiceParseException("Invalid initial metadata header: " + key); - } - initialMetadata.add(HeaderValue.create(key, header.getRawValue())); + headerValue = HeaderValue.create(key, header.getRawValue()); } else { - if (!XdsHeaderValidator.isValid(key, header.getValue().length())) { - throw new GrpcServiceParseException("Invalid initial metadata header: " + key); - } - initialMetadata.add(HeaderValue.create(key, header.getValue())); + headerValue = HeaderValue.create(key, header.getValue()); + } + if (HeaderValueValidationUtils.shouldIgnore(headerValue)) { + throw new GrpcServiceParseException("Invalid initial metadata header: " + key); } + initialMetadata.add(headerValue); } builder.initialMetadata(initialMetadata.build()); diff --git a/xds/src/main/java/io/grpc/xds/internal/grpcservice/HeaderValueValidationUtils.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/HeaderValueValidationUtils.java new file mode 100644 index 00000000000..5e1eff04792 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/HeaderValueValidationUtils.java @@ -0,0 +1,67 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.grpcservice; + +import java.util.Locale; + +/** + * Utility class for validating HTTP headers. + */ +public final class HeaderValueValidationUtils { + public static final int MAX_HEADER_LENGTH = 16384; + + private HeaderValueValidationUtils() {} + + /** + * Returns true if the header key should be ignored for mutations or validation. + * + * @param key The header key (e.g., "content-type") + */ + public static boolean shouldIgnore(String key) { + if (key.isEmpty() || key.length() > MAX_HEADER_LENGTH) { + return true; + } + if (!key.equals(key.toLowerCase(Locale.ROOT))) { + return true; + } + if (key.startsWith("grpc-")) { + return true; + } + if (key.startsWith(":") || key.equals("host")) { + return true; + } + return false; + } + + /** + * Returns true if the header value should be ignored. + * + * @param header The HeaderValue containing key and values + */ + public static boolean shouldIgnore(HeaderValue header) { + if (shouldIgnore(header.key())) { + return true; + } + if (header.value().isPresent() && header.value().get().length() > MAX_HEADER_LENGTH) { + return true; + } + if (header.rawValue().isPresent() && header.rawValue().get().size() > MAX_HEADER_LENGTH) { + return true; + } + return false; + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/XdsHeaderValidatorTest.java b/xds/src/test/java/io/grpc/xds/internal/XdsHeaderValidatorTest.java deleted file mode 100644 index c6c99c6d46f..00000000000 --- a/xds/src/test/java/io/grpc/xds/internal/XdsHeaderValidatorTest.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2025 The gRPC Authors - * - * Licensed 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 io.grpc.xds.internal; - -import static com.google.common.truth.Truth.assertThat; - -import com.google.common.base.Strings; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; - -@RunWith(JUnit4.class) -public class XdsHeaderValidatorTest { - - @Test - public void isValid_validKeyAndLength_returnsTrue() { - assertThat(XdsHeaderValidator.isValid("valid-key", 10)).isTrue(); - } - - @Test - public void isValid_emptyKey_returnsFalse() { - assertThat(XdsHeaderValidator.isValid("", 10)).isFalse(); - } - - @Test - public void isValid_uppercaseKey_returnsFalse() { - assertThat(XdsHeaderValidator.isValid("Invalid-Key", 10)).isFalse(); - } - - @Test - public void isValid_keyExceedsMaxLength_returnsFalse() { - String longKey = Strings.repeat("k", 16385); - assertThat(XdsHeaderValidator.isValid(longKey, 10)).isFalse(); - } - - @Test - public void isValid_valueExceedsMaxLength_returnsFalse() { - assertThat(XdsHeaderValidator.isValid("valid-key", 16385)).isFalse(); - } - - @Test - public void isValid_hostKey_returnsFalse() { - assertThat(XdsHeaderValidator.isValid("host", 10)).isFalse(); - } - - @Test - public void isValid_pseudoHeaderKey_returnsFalse() { - assertThat(XdsHeaderValidator.isValid(":method", 10)).isFalse(); - } -} diff --git a/xds/src/test/java/io/grpc/xds/internal/grpcservice/HeaderValueValidationUtilsTest.java b/xds/src/test/java/io/grpc/xds/internal/grpcservice/HeaderValueValidationUtilsTest.java new file mode 100644 index 00000000000..993abfdc545 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/grpcservice/HeaderValueValidationUtilsTest.java @@ -0,0 +1,87 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.grpcservice; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.protobuf.ByteString; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Unit tests for {@link HeaderValueValidationUtils}. + */ +@RunWith(JUnit4.class) +public class HeaderValueValidationUtilsTest { + + @Test + public void shouldIgnore_string_emptyKey() { + assertThat(HeaderValueValidationUtils.shouldIgnore("")).isTrue(); + } + + @Test + public void shouldIgnore_string_tooLongKey() { + String longKey = new String(new char[16385]).replace('\0', 'a'); + assertThat(HeaderValueValidationUtils.shouldIgnore(longKey)).isTrue(); + } + + @Test + public void shouldIgnore_string_notLowercase() { + assertThat(HeaderValueValidationUtils.shouldIgnore("Content-Type")).isTrue(); + } + + @Test + public void shouldIgnore_string_grpcPrefix() { + assertThat(HeaderValueValidationUtils.shouldIgnore("grpc-timeout")).isTrue(); + } + + @Test + public void shouldIgnore_string_systemHeader_colon() { + assertThat(HeaderValueValidationUtils.shouldIgnore(":authority")).isTrue(); + } + + @Test + public void shouldIgnore_string_systemHeader_host() { + assertThat(HeaderValueValidationUtils.shouldIgnore("host")).isTrue(); + } + + @Test + public void shouldIgnore_string_valid() { + assertThat(HeaderValueValidationUtils.shouldIgnore("content-type")).isFalse(); + } + + @Test + public void shouldIgnore_headerValue_tooLongValue() { + String longValue = new String(new char[16385]).replace('\0', 'v'); + HeaderValue header = HeaderValue.create("content-type", longValue); + assertThat(HeaderValueValidationUtils.shouldIgnore(header)).isTrue(); + } + + @Test + public void shouldIgnore_headerValue_tooLongRawValue() { + ByteString longRawValue = ByteString.copyFrom(new byte[16385]); + HeaderValue header = HeaderValue.create("content-type", longRawValue); + assertThat(HeaderValueValidationUtils.shouldIgnore(header)).isTrue(); + } + + @Test + public void shouldIgnore_headerValue_valid() { + HeaderValue header = HeaderValue.create("content-type", "application/grpc"); + assertThat(HeaderValueValidationUtils.shouldIgnore(header)).isFalse(); + } +} From d4a77593439d88662ceddc687c4f9a90547bf5d3 Mon Sep 17 00:00:00 2001 From: Saurav Date: Thu, 6 Nov 2025 10:14:30 +0000 Subject: [PATCH 4/6] feat(xds): Add CachedChannelManager for caching channel instances --- .../grpcservice/CachedChannelManager.java | 128 ++++++++++++++++++ .../grpcservice/CachedChannelManagerTest.java | 123 +++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 xds/src/main/java/io/grpc/xds/internal/grpcservice/CachedChannelManager.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/grpcservice/CachedChannelManagerTest.java diff --git a/xds/src/main/java/io/grpc/xds/internal/grpcservice/CachedChannelManager.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/CachedChannelManager.java new file mode 100644 index 00000000000..a6d7019a908 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/CachedChannelManager.java @@ -0,0 +1,128 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.grpcservice; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.auto.value.AutoValue; +import io.grpc.ManagedChannel; +import io.grpc.xds.internal.grpcservice.GrpcServiceConfig.GoogleGrpcConfig; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +/** + * Concrete class managing the lifecycle of a single ManagedChannel for a GrpcServiceConfig. + */ +public class CachedChannelManager { + private final Function channelCreator; + private final Object lock = new Object(); + + private final AtomicReference channelHolder = new AtomicReference<>(); + + /** + * Default constructor for production that creates a channel using the config's target and + * credentials. + */ + public CachedChannelManager() { + this(config -> { + GoogleGrpcConfig googleGrpc = config.googleGrpc(); + return io.grpc.Grpc.newChannelBuilder(googleGrpc.target(), + googleGrpc.configuredChannelCredentials().channelCredentials()).build(); + }); + } + + /** + * Constructor for testing to inject a channel creator. + */ + public CachedChannelManager(Function channelCreator) { + this.channelCreator = checkNotNull(channelCreator, "channelCreator"); + } + + /** + * Returns a ManagedChannel for the given configuration. If the target or credentials config + * changes, the old channel is shut down and a new one is created. + */ + public ManagedChannel getChannel(GrpcServiceConfig config) { + GoogleGrpcConfig googleGrpc = config.googleGrpc(); + ChannelKey newChannelKey = ChannelKey.of( + googleGrpc.target(), + googleGrpc.configuredChannelCredentials().channelCredsConfig()); + + // 1. Fast path: Lock-free read + ChannelHolder holder = channelHolder.get(); + if (holder != null && holder.channelKey().equals(newChannelKey)) { + return holder.channel(); + } + + ManagedChannel oldChannel = null; + ManagedChannel newChannel; + + // 2. Slow path: Update with locking + synchronized (lock) { + holder = channelHolder.get(); // Double check + if (holder != null && holder.channelKey().equals(newChannelKey)) { + return holder.channel(); + } + + // 3. Create inside lock to avoid creation storms + newChannel = channelCreator.apply(config); + ChannelHolder newHolder = ChannelHolder.create(newChannelKey, newChannel); + + if (holder != null) { + oldChannel = holder.channel(); + } + channelHolder.set(newHolder); + } + + // 4. Shutdown outside lock + if (oldChannel != null) { + oldChannel.shutdown(); + } + + return newChannel; + } + + /** Removes underlying resources on shutdown. */ + public void close() { + ChannelHolder holder = channelHolder.get(); + if (holder != null) { + holder.channel().shutdown(); + } + } + + @AutoValue + abstract static class ChannelKey { + static ChannelKey of(String target, ChannelCredsConfig credentialsConfig) { + return new AutoValue_CachedChannelManager_ChannelKey(target, credentialsConfig); + } + + abstract String target(); + + abstract ChannelCredsConfig channelCredsConfig(); + } + + @AutoValue + abstract static class ChannelHolder { + static ChannelHolder create(ChannelKey channelKey, ManagedChannel channel) { + return new AutoValue_CachedChannelManager_ChannelHolder(channelKey, channel); + } + + abstract ChannelKey channelKey(); + + abstract ManagedChannel channel(); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/grpcservice/CachedChannelManagerTest.java b/xds/src/test/java/io/grpc/xds/internal/grpcservice/CachedChannelManagerTest.java new file mode 100644 index 00000000000..3fdf9ed02eb --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/grpcservice/CachedChannelManagerTest.java @@ -0,0 +1,123 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.grpcservice; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableList; +import io.grpc.ManagedChannel; +import io.grpc.xds.internal.grpcservice.GrpcServiceConfig.GoogleGrpcConfig; +import java.util.function.Function; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +/** + * Unit tests for {@link CachedChannelManager}. + */ +@RunWith(JUnit4.class) +public class CachedChannelManagerTest { + + @Rule + public final MockitoRule mocks = MockitoJUnit.rule(); + + @Mock + private Function mockCreator; + + @Mock + private ManagedChannel mockChannel1; + + @Mock + private ManagedChannel mockChannel2; + + private CachedChannelManager manager; + + private GrpcServiceConfig config1; + private GrpcServiceConfig config2; + + @Before + public void setUp() { + manager = new CachedChannelManager(mockCreator); + + config1 = buildConfig("authz.service.com", "creds1"); + config2 = buildConfig("authz.service.com", "creds2"); // Different creds instance + } + + private GrpcServiceConfig buildConfig(String target, String credsType) { + ChannelCredsConfig credsConfig = mock(ChannelCredsConfig.class); + when(credsConfig.type()).thenReturn(credsType); + + ConfiguredChannelCredentials creds = ConfiguredChannelCredentials.create( + mock(io.grpc.ChannelCredentials.class), credsConfig); + + GoogleGrpcConfig googleGrpc = GoogleGrpcConfig.builder() + .target(target) + .configuredChannelCredentials(creds) + .build(); + + return GrpcServiceConfig.newBuilder() + .googleGrpc(googleGrpc) + .initialMetadata(ImmutableList.of()) + .build(); + } + + @Test + public void getChannel_sameConfig_returnsCached() { + when(mockCreator.apply(config1)).thenReturn(mockChannel1); + + ManagedChannel channela = manager.getChannel(config1); + ManagedChannel channelb = manager.getChannel(config1); + + assertThat(channela).isSameInstanceAs(mockChannel1); + assertThat(channelb).isSameInstanceAs(mockChannel1); + verify(mockCreator, org.mockito.Mockito.times(1)).apply(config1); + } + + @Test + public void getChannel_differentConfig_shutsDownOldAndReturnsNew() { + when(mockCreator.apply(config1)).thenReturn(mockChannel1); + when(mockCreator.apply(config2)).thenReturn(mockChannel2); + + ManagedChannel channel1 = manager.getChannel(config1); + assertThat(channel1).isSameInstanceAs(mockChannel1); + + ManagedChannel channel2 = manager.getChannel(config2); + assertThat(channel2).isSameInstanceAs(mockChannel2); + + verify(mockChannel1).shutdown(); + verify(mockCreator, org.mockito.Mockito.times(1)).apply(config1); + verify(mockCreator, org.mockito.Mockito.times(1)).apply(config2); + } + + @Test + public void close_shutsDownChannel() { + when(mockCreator.apply(config1)).thenReturn(mockChannel1); + + manager.getChannel(config1); + manager.close(); + + verify(mockChannel1).shutdown(); + } +} From f547419968ddad5e892931dbd6dceb4ddf8e873d Mon Sep 17 00:00:00 2001 From: Saurav Date: Fri, 24 Oct 2025 13:58:34 +0000 Subject: [PATCH 5/6] feat(xds): Add header mutations library This commit introduces a library for handling header mutations as specified by the xDS protocol. This library provides the core functionality for modifying request and response headers based on a set of rules. The main components of this library are: - `HeaderMutator`: Applies header mutations to `Metadata` objects. - `HeaderMutationFilter`: Filters header mutations based on a set of configurable rules, such as disallowing mutations of system headers. - `HeaderMutations`: A value class that represents the set of mutations to be applied to request and response headers. - `HeaderMutationDisallowedException`: An exception that is thrown when a disallowed header mutation is attempted. This commit also includes comprehensive unit tests for the new library. --- .../HeaderMutationDisallowedException.java | 32 ++ .../headermutations/HeaderMutationFilter.java | 172 ++++++++++ .../headermutations/HeaderMutations.java | 58 ++++ .../headermutations/HeaderMutator.java | 143 ++++++++ .../headermutations/HeaderValueOption.java | 50 +++ .../HeaderMutationFilterTest.java | 245 ++++++++++++++ .../headermutations/HeaderMutationsTest.java | 50 +++ .../headermutations/HeaderMutatorTest.java | 311 ++++++++++++++++++ .../HeaderValueOptionTest.java | 40 +++ 9 files changed, 1101 insertions(+) create mode 100644 xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationDisallowedException.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationFilter.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutations.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutator.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderValueOption.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationFilterTest.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationsTest.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutatorTest.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderValueOptionTest.java diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationDisallowedException.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationDisallowedException.java new file mode 100644 index 00000000000..b8d4eb582fb --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationDisallowedException.java @@ -0,0 +1,32 @@ +/* + * Copyright 2024 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.headermutations; + +import io.grpc.Status; +import io.grpc.StatusException; + +/** + * Exception thrown when a header mutation is disallowed. + */ +public final class HeaderMutationDisallowedException extends StatusException { + + private static final long serialVersionUID = 1L; + + public HeaderMutationDisallowedException(String message) { + super(Status.INTERNAL.withDescription(message)); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationFilter.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationFilter.java new file mode 100644 index 00000000000..0452354d823 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationFilter.java @@ -0,0 +1,172 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.headermutations; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import io.envoyproxy.envoy.config.core.v3.HeaderValueOption; +import io.grpc.xds.internal.headermutations.HeaderMutations.RequestHeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderMutations.ResponseHeaderMutations; +import java.util.Collection; +import java.util.Locale; +import java.util.Optional; +import java.util.function.Predicate; + +/** + * The HeaderMutationFilter class is responsible for filtering header mutations based on a given set + * of rules. + */ +public interface HeaderMutationFilter { + + /** + * A factory for creating {@link HeaderMutationFilter} instances. + */ + @FunctionalInterface + interface Factory { + /** + * Creates a new instance of {@code HeaderMutationFilter}. + * + * @param mutationRules The rules for header mutations. If an empty {@code Optional} is + * provided, all header mutations are allowed by default, except for certain system + * headers. If a {@link HeaderMutationRulesConfig} is provided, mutations will be + * filtered based on the specified rules. + */ + HeaderMutationFilter create(Optional mutationRules); + } + + /** + * The default factory for creating {@link HeaderMutationFilter} instances. + */ + Factory INSTANCE = HeaderMutationFilterImpl::new; + + /** + * Filters the given header mutations based on the configured rules and returns the allowed + * mutations. + * + * @param mutations The header mutations to filter + * @return The allowed header mutations. + * @throws HeaderMutationDisallowedException if a disallowed mutation is encountered and the rules + * specify that this should be an error. + */ + HeaderMutations filter(HeaderMutations mutations) throws HeaderMutationDisallowedException; + + /** Default implementation of {@link HeaderMutationFilter}. */ + final class HeaderMutationFilterImpl implements HeaderMutationFilter { + private final Optional mutationRules; + + /** + * Set of HTTP/2 pseudo-headers and the host header that are critical for routing and protocol + * correctness. These headers cannot be mutated by user configuration. + */ + private static final ImmutableSet IMMUTABLE_HEADERS = + ImmutableSet.of("host", ":authority", ":scheme", ":method"); + + private HeaderMutationFilterImpl(Optional mutationRules) { // NOPMD + this.mutationRules = mutationRules; + } + + @Override + public HeaderMutations filter(HeaderMutations mutations) + throws HeaderMutationDisallowedException { + ImmutableList allowedRequestHeaders = + filterCollection(mutations.requestMutations().headers(), + header -> isHeaderMutationAllowed(header.getHeader().getKey()) + && !appendsSystemHeader(header)); + ImmutableList allowedRequestHeadersToRemove = + filterCollection(mutations.requestMutations().headersToRemove(), + header -> isHeaderMutationAllowed(header) && isHeaderRemovalAllowed(header)); + ImmutableList allowedResponseHeaders = + filterCollection(mutations.responseMutations().headers(), + header -> isHeaderMutationAllowed(header.getHeader().getKey()) + && !appendsSystemHeader(header)); + return HeaderMutations.create( + RequestHeaderMutations.create(allowedRequestHeaders, allowedRequestHeadersToRemove), + ResponseHeaderMutations.create(allowedResponseHeaders)); + } + + /** + * A generic helper to filter a collection based on a predicate. + * + * @param items The collection of items to filter. + * @param isAllowedPredicate The predicate to apply to each item. + * @param The type of items in the collection. + * @return An immutable list of allowed items. + * @throws HeaderMutationDisallowedException if an item is disallowed and disallowIsError is + * true. + */ + private ImmutableList filterCollection(Collection items, + Predicate isAllowedPredicate) throws HeaderMutationDisallowedException { + ImmutableList.Builder allowed = ImmutableList.builder(); + for (T item : items) { + if (isAllowedPredicate.test(item)) { + allowed.add(item); + } else if (disallowIsError()) { + throw new HeaderMutationDisallowedException( + "Header mutation disallowed for header: " + item); + } + } + return allowed.build(); + } + + private boolean isHeaderRemovalAllowed(String headerKey) { + return !isSystemHeaderKey(headerKey); + } + + private boolean appendsSystemHeader(HeaderValueOption headerValueOption) { + String key = headerValueOption.getHeader().getKey(); + boolean isAppend = headerValueOption + .getAppendAction() == HeaderValueOption.HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD; + return isAppend && isSystemHeaderKey(key); + } + + private boolean isSystemHeaderKey(String key) { + return key.startsWith(":") || key.toLowerCase(Locale.ROOT).equals("host"); + } + + private boolean isHeaderMutationAllowed(String headerName) { + String lowerCaseHeaderName = headerName.toLowerCase(Locale.ROOT); + if (IMMUTABLE_HEADERS.contains(lowerCaseHeaderName)) { + return false; + } + return mutationRules.map(rules -> isHeaderMutationAllowed(lowerCaseHeaderName, rules)) + .orElse(true); + } + + private boolean isHeaderMutationAllowed(String lowerCaseHeaderName, + HeaderMutationRulesConfig rules) { + // TODO(sauravzg): The priority is slightly unclear in the spec. + // Both `disallowAll` and `disallow_expression` take precedence over `all other + // settings`. + // `allow_expression` takes precedence over everything except `disallow_expression`. + // This is a conflict between ordering for `allow_expression` and `disallowAll`. + // Choosing to proceed with current envoy implementation which favors `allow_expression` over + // `disallowAll`. + if (rules.disallowExpression().isPresent() + && rules.disallowExpression().get().matcher(lowerCaseHeaderName).matches()) { + return false; + } + if (rules.allowExpression().isPresent()) { + return rules.allowExpression().get().matcher(lowerCaseHeaderName).matches(); + } + return !rules.disallowAll(); + } + + private boolean disallowIsError() { + return mutationRules.map(HeaderMutationRulesConfig::disallowIsError).orElse(false); + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutations.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutations.java new file mode 100644 index 00000000000..e0cb3daede3 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutations.java @@ -0,0 +1,58 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.headermutations; + +import com.google.auto.value.AutoValue; +import com.google.common.collect.ImmutableList; +import io.envoyproxy.envoy.config.core.v3.HeaderValueOption; + +/** A collection of header mutations for both request and response headers. */ +@AutoValue +public abstract class HeaderMutations { + + public static HeaderMutations create(RequestHeaderMutations requestMutations, + ResponseHeaderMutations responseMutations) { + return new AutoValue_HeaderMutations(requestMutations, responseMutations); + } + + public abstract RequestHeaderMutations requestMutations(); + + public abstract ResponseHeaderMutations responseMutations(); + + /** Represents mutations for request headers. */ + @AutoValue + public abstract static class RequestHeaderMutations { + public static RequestHeaderMutations create(ImmutableList headers, + ImmutableList headersToRemove) { + return new AutoValue_HeaderMutations_RequestHeaderMutations(headers, headersToRemove); + } + + public abstract ImmutableList headers(); + + public abstract ImmutableList headersToRemove(); + } + + /** Represents mutations for response headers. */ + @AutoValue + public abstract static class ResponseHeaderMutations { + public static ResponseHeaderMutations create(ImmutableList headers) { + return new AutoValue_HeaderMutations_ResponseHeaderMutations(headers); + } + + public abstract ImmutableList headers(); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutator.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutator.java new file mode 100644 index 00000000000..de5b946bbc7 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutator.java @@ -0,0 +1,143 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.headermutations; + +import com.google.common.io.BaseEncoding; +import io.envoyproxy.envoy.config.core.v3.HeaderValue; +import io.envoyproxy.envoy.config.core.v3.HeaderValueOption; +import io.envoyproxy.envoy.config.core.v3.HeaderValueOption.HeaderAppendAction; +import io.grpc.Metadata; +import io.grpc.xds.internal.headermutations.HeaderMutations.RequestHeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderMutations.ResponseHeaderMutations; +import java.nio.charset.StandardCharsets; +import java.util.logging.Logger; + +/** + * The HeaderMutator class is an implementation of the HeaderMutator interface. It provides methods + * to apply header mutations to a given set of headers based on a given set of rules. + */ +public interface HeaderMutator { + /** + * Creates a new instance of {@code HeaderMutator}. + */ + static HeaderMutator create() { + return new HeaderMutatorImpl(); + } + + /** + * Applies the given header mutations to the provided metadata headers. + * + * @param mutations The header mutations to apply. + * @param headers The metadata headers to which the mutations will be applied. + */ + void applyRequestMutations(RequestHeaderMutations mutations, Metadata headers); + + + /** + * Applies the given header mutations to the provided metadata headers. + * + * @param mutations The header mutations to apply. + * @param headers The metadata headers to which the mutations will be applied. + */ + void applyResponseMutations(ResponseHeaderMutations mutations, Metadata headers); + + /** Default implementation of {@link HeaderMutator}. */ + final class HeaderMutatorImpl implements HeaderMutator { + + private static final Logger logger = Logger.getLogger(HeaderMutatorImpl.class.getName()); + + @Override + public void applyRequestMutations(final RequestHeaderMutations mutations, Metadata headers) { + // TODO(sauravzg): The specification is not clear on order of header removals and additions. + // in case of conflicts. Copying the order from Envoy here, which does removals at the end. + applyHeaderUpdates(mutations.headers(), headers); + for (String headerToRemove : mutations.headersToRemove()) { + headers.discardAll(Metadata.Key.of(headerToRemove, Metadata.ASCII_STRING_MARSHALLER)); + } + } + + @Override + public void applyResponseMutations(final ResponseHeaderMutations mutations, Metadata headers) { + applyHeaderUpdates(mutations.headers(), headers); + } + + private void applyHeaderUpdates(final Iterable headerOptions, + Metadata headers) { + for (HeaderValueOption headerOption : headerOptions) { + HeaderValue headerValue = headerOption.getHeader(); + updateHeader(headerValue, headerOption.getAppendAction(), headers); + } + } + + private void updateHeader(final HeaderValue header, final HeaderAppendAction action, + Metadata mutableHeaders) { + if (header.getKey().endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + updateHeader(action, Metadata.Key.of(header.getKey(), Metadata.BINARY_BYTE_MARSHALLER), + getBinaryHeaderValue(header), mutableHeaders); + } else { + updateHeader(action, Metadata.Key.of(header.getKey(), Metadata.ASCII_STRING_MARSHALLER), + getAsciiValue(header), mutableHeaders); + } + } + + private void updateHeader(final HeaderAppendAction action, final Metadata.Key key, + final T value, Metadata mutableHeaders) { + switch (action) { + case APPEND_IF_EXISTS_OR_ADD: + mutableHeaders.put(key, value); + break; + case ADD_IF_ABSENT: + if (!mutableHeaders.containsKey(key)) { + mutableHeaders.put(key, value); + } + break; + case OVERWRITE_IF_EXISTS_OR_ADD: + mutableHeaders.discardAll(key); + mutableHeaders.put(key, value); + break; + case OVERWRITE_IF_EXISTS: + if (mutableHeaders.containsKey(key)) { + mutableHeaders.discardAll(key); + mutableHeaders.put(key, value); + } + break; + case UNRECOGNIZED: + // Ignore invalid value + logger.warning("Unrecognized HeaderAppendAction: " + action); + break; + default: + // Should be unreachable unless there's a proto schema mismatch. + logger.warning("Unknown HeaderAppendAction: " + action); + } + } + + private byte[] getBinaryHeaderValue(HeaderValue header) { + return BaseEncoding.base64().decode(getAsciiValue(header)); + } + + private String getAsciiValue(HeaderValue header) { + // TODO(sauravzg): GRPC only supports base64 encoded binary headers, so we decode bytes to + // String using `StandardCharsets.US_ASCII`. + // Envoy's spec `raw_value` specification can contain non UTF-8 bytes, so this may potentially + // cause an exception or corruption. + if (!header.getRawValue().isEmpty()) { + return header.getRawValue().toString(StandardCharsets.US_ASCII); + } + return header.getValue(); + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderValueOption.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderValueOption.java new file mode 100644 index 00000000000..6cb96da864d --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderValueOption.java @@ -0,0 +1,50 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.headermutations; + +import com.google.auto.value.AutoValue; +import io.grpc.xds.internal.grpcservice.HeaderValue; + +/** + * Represents a header option to be appended or mutated as part of xDS configuration. + * Avoids direct dependency on Envoy's proto objects. + */ +@AutoValue +public abstract class HeaderValueOption { + + public static HeaderValueOption create( + HeaderValue header, HeaderAppendAction appendAction, boolean keepEmptyValue) { + return new AutoValue_HeaderValueOption(header, appendAction, keepEmptyValue); + } + + public abstract HeaderValue header(); + + public abstract HeaderAppendAction appendAction(); + + public abstract boolean keepEmptyValue(); + + /** + * Defines the action to take when appending headers. + * Mirrors io.envoyproxy.envoy.config.core.v3.HeaderValueOption.HeaderAppendAction. + */ + public enum HeaderAppendAction { + APPEND_IF_EXISTS_OR_ADD, + ADD_IF_ABSENT, + OVERWRITE_IF_EXISTS_OR_ADD, + OVERWRITE_IF_EXISTS + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationFilterTest.java b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationFilterTest.java new file mode 100644 index 00000000000..e73460924c7 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationFilterTest.java @@ -0,0 +1,245 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.headermutations; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableList; +import io.envoyproxy.envoy.config.core.v3.HeaderValue; +import io.envoyproxy.envoy.config.core.v3.HeaderValueOption; +import io.envoyproxy.envoy.config.core.v3.HeaderValueOption.HeaderAppendAction; +import io.grpc.xds.internal.headermutations.HeaderMutations.RequestHeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderMutations.ResponseHeaderMutations; +import java.util.Optional; +import java.util.regex.Pattern; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class HeaderMutationFilterTest { + + private static HeaderValueOption header(String key, String value) { + return HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey(key).setValue(value)).build(); + } + + private static HeaderValueOption header(String key, String value, HeaderAppendAction action) { + return HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey(key).setValue(value)).setAppendAction(action) + .build(); + } + + @Test + public void filter_removesImmutableHeaders() throws HeaderMutationDisallowedException { + HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.empty()); + HeaderMutations mutations = HeaderMutations.create( + RequestHeaderMutations.create( + ImmutableList.of(header("add-key", "add-value"), header(":authority", "new-authority"), + header("host", "new-host"), header(":scheme", "https"), header(":method", "PUT")), + ImmutableList.of("remove-key", "host", ":authority", ":scheme", ":method")), + ResponseHeaderMutations.create(ImmutableList.of(header("resp-add-key", "resp-add-value"), + header(":scheme", "https")))); + + HeaderMutations filtered = filter.filter(mutations); + + assertThat(filtered.requestMutations().headers()) + .containsExactly(header("add-key", "add-value")); + assertThat(filtered.requestMutations().headersToRemove()).containsExactly("remove-key"); + assertThat(filtered.responseMutations().headers()) + .containsExactly(header("resp-add-key", "resp-add-value")); + } + + @Test + public void filter_cannotAppendToSystemHeaders() throws HeaderMutationDisallowedException { + HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.empty()); + HeaderMutations mutations = + HeaderMutations.create( + RequestHeaderMutations.create( + ImmutableList.of( + header("add-key", "add-value", HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD), + header(":authority", "new-authority", + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD), + header("host", "new-host", HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD), + header(":path", "/new-path", HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD)), + ImmutableList.of()), + ResponseHeaderMutations.create(ImmutableList + .of(header("host", "new-host", HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD)))); + + HeaderMutations filtered = filter.filter(mutations); + + assertThat(filtered.requestMutations().headers()).containsExactly( + header("add-key", "add-value", HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD)); + assertThat(filtered.responseMutations().headers()).isEmpty(); + } + + @Test + public void filter_cannotRemoveSystemHeaders() throws HeaderMutationDisallowedException { + HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.empty()); + HeaderMutations mutations = HeaderMutations.create( + RequestHeaderMutations.create(ImmutableList.of(), + ImmutableList.of("remove-key", "host", ":foo", ":bar")), + ResponseHeaderMutations.create(ImmutableList.of())); + + HeaderMutations filtered = filter.filter(mutations); + + assertThat(filtered.requestMutations().headersToRemove()).containsExactly("remove-key"); + } + + @Test + public void filter_canOverrideSystemHeadersNotInImmutableHeaders() + throws HeaderMutationDisallowedException { + HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.empty()); + HeaderMutations mutations = HeaderMutations.create( + RequestHeaderMutations.create( + ImmutableList.of(header("user-agent", "new-agent"), + header(":path", "/new/path", HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD), + header(":grpc-trace-bin", "binary-value", HeaderAppendAction.ADD_IF_ABSENT)), + ImmutableList.of()), + ResponseHeaderMutations.create(ImmutableList + .of(header(":alt-svc", "h3=:443", HeaderAppendAction.OVERWRITE_IF_EXISTS)))); + + HeaderMutations filtered = filter.filter(mutations); + + assertThat(filtered.requestMutations().headers()).containsExactly( + header("user-agent", "new-agent"), + header(":path", "/new/path", HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD), + header(":grpc-trace-bin", "binary-value", HeaderAppendAction.ADD_IF_ABSENT)); + assertThat(filtered.responseMutations().headers()) + .containsExactly(header(":alt-svc", "h3=:443", HeaderAppendAction.OVERWRITE_IF_EXISTS)); + } + + @Test + public void filter_disallowAll_disablesAllModifications() + throws HeaderMutationDisallowedException { + HeaderMutationRulesConfig rules = HeaderMutationRulesConfig.builder().disallowAll(true).build(); + HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.of(rules)); + HeaderMutations mutations = HeaderMutations.create( + RequestHeaderMutations.create(ImmutableList.of(header("add-key", "add-value")), + ImmutableList.of("remove-key")), + ResponseHeaderMutations.create(ImmutableList.of(header("resp-add-key", "resp-add-value")))); + + HeaderMutations filtered = filter.filter(mutations); + + assertThat(filtered.requestMutations().headers()).isEmpty(); + assertThat(filtered.requestMutations().headersToRemove()).isEmpty(); + assertThat(filtered.responseMutations().headers()).isEmpty(); + } + + @Test + public void filter_disallowExpression_filtersRelevantExpressions() + throws HeaderMutationDisallowedException { + HeaderMutationRulesConfig rules = HeaderMutationRulesConfig.builder() + .disallowExpression(Pattern.compile("^x-private-.*")).build(); + HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.of(rules)); + HeaderMutations mutations = HeaderMutations.create( + RequestHeaderMutations.create( + ImmutableList.of(header("x-public", "value"), header("x-private-key", "value")), + ImmutableList.of("x-public-remove", "x-private-remove")), + ResponseHeaderMutations.create( + ImmutableList.of(header("x-public-resp", "value"), header("x-private-resp", "value")))); + + HeaderMutations filtered = filter.filter(mutations); + + assertThat(filtered.requestMutations().headers()).containsExactly(header("x-public", "value")); + assertThat(filtered.requestMutations().headersToRemove()).containsExactly("x-public-remove"); + assertThat(filtered.responseMutations().headers()) + .containsExactly(header("x-public-resp", "value")); + } + + @Test + public void filter_allowExpression_onlyAllowsRelevantExpressions() + throws HeaderMutationDisallowedException { + HeaderMutationRulesConfig rules = HeaderMutationRulesConfig.builder() + .allowExpression(Pattern.compile("^x-allowed-.*")).build(); + HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.of(rules)); + HeaderMutations mutations = + HeaderMutations.create( + RequestHeaderMutations.create( + ImmutableList.of(header("x-allowed-key", "value"), + header("not-allowed-key", "value")), + ImmutableList.of("x-allowed-remove", "not-allowed-remove")), + ResponseHeaderMutations.create(ImmutableList.of(header("x-allowed-resp-key", "value"), + header("not-allowed-resp-key", "value")))); + + HeaderMutations filtered = filter.filter(mutations); + + assertThat(filtered.requestMutations().headers()) + .containsExactly(header("x-allowed-key", "value")); + assertThat(filtered.requestMutations().headersToRemove()).containsExactly("x-allowed-remove"); + assertThat(filtered.responseMutations().headers()) + .containsExactly(header("x-allowed-resp-key", "value")); + } + + @Test + public void filter_allowExpression_overridesDisallowAll() + throws HeaderMutationDisallowedException { + HeaderMutationRulesConfig rules = HeaderMutationRulesConfig.builder().disallowAll(true) + .allowExpression(Pattern.compile("^x-allowed-.*")).build(); + HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.of(rules)); + HeaderMutations mutations = HeaderMutations.create( + RequestHeaderMutations.create( + ImmutableList.of(header("x-allowed-key", "value"), header("not-allowed", "value")), + ImmutableList.of("x-allowed-remove", "not-allowed-remove")), + ResponseHeaderMutations.create(ImmutableList.of(header("x-allowed-resp-key", "value"), + header("not-allowed-resp-key", "value")))); + + HeaderMutations filtered = filter.filter(mutations); + + assertThat(filtered.requestMutations().headers()) + .containsExactly(header("x-allowed-key", "value")); + assertThat(filtered.requestMutations().headersToRemove()).containsExactly("x-allowed-remove"); + assertThat(filtered.responseMutations().headers()) + .containsExactly(header("x-allowed-resp-key", "value")); + } + + @Test(expected = HeaderMutationDisallowedException.class) + public void filter_disallowIsError_throwsExceptionOnDisallowed() + throws HeaderMutationDisallowedException { + HeaderMutationRulesConfig rules = + HeaderMutationRulesConfig.builder().disallowAll(true).disallowIsError(true).build(); + HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.of(rules)); + HeaderMutations mutations = HeaderMutations.create(RequestHeaderMutations + .create(ImmutableList.of(header("add-key", "add-value")), ImmutableList.of()), + ResponseHeaderMutations.create(ImmutableList.of())); + filter.filter(mutations); + } + + @Test(expected = HeaderMutationDisallowedException.class) + public void filter_disallowIsError_throwsExceptionOnDisallowedRemove() + throws HeaderMutationDisallowedException { + HeaderMutationRulesConfig rules = + HeaderMutationRulesConfig.builder().disallowAll(true).disallowIsError(true).build(); + HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.of(rules)); + HeaderMutations mutations = HeaderMutations.create( + RequestHeaderMutations.create(ImmutableList.of(), ImmutableList.of("remove-key")), + ResponseHeaderMutations.create(ImmutableList.of())); + filter.filter(mutations); + } + + @Test(expected = HeaderMutationDisallowedException.class) + public void filter_disallowIsError_throwsExceptionOnDisallowedResponseHeader() + throws HeaderMutationDisallowedException { + HeaderMutationRulesConfig rules = + HeaderMutationRulesConfig.builder().disallowAll(true).disallowIsError(true).build(); + HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.of(rules)); + HeaderMutations mutations = HeaderMutations.create( + RequestHeaderMutations.create(ImmutableList.of(), ImmutableList.of()), + ResponseHeaderMutations.create(ImmutableList.of(header("resp-add-key", "resp-add-value")))); + filter.filter(mutations); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationsTest.java b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationsTest.java new file mode 100644 index 00000000000..f1dc0561692 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationsTest.java @@ -0,0 +1,50 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.headermutations; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableList; +import io.envoyproxy.envoy.config.core.v3.HeaderValue; +import io.envoyproxy.envoy.config.core.v3.HeaderValueOption; +import io.grpc.xds.internal.headermutations.HeaderMutations.RequestHeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderMutations.ResponseHeaderMutations; +import org.junit.Test; + +public class HeaderMutationsTest { + @Test + public void testCreate() { + HeaderValueOption reqHeader = HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey("req-key").setValue("req-value").build()) + .build(); + RequestHeaderMutations requestMutations = RequestHeaderMutations + .create(ImmutableList.of(reqHeader), ImmutableList.of("remove-req-key")); + assertThat(requestMutations.headers()).containsExactly(reqHeader); + assertThat(requestMutations.headersToRemove()).containsExactly("remove-req-key"); + + HeaderValueOption respHeader = HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey("resp-key").setValue("resp-value").build()) + .build(); + ResponseHeaderMutations responseMutations = + ResponseHeaderMutations.create(ImmutableList.of(respHeader)); + assertThat(responseMutations.headers()).containsExactly(respHeader); + + HeaderMutations mutations = HeaderMutations.create(requestMutations, responseMutations); + assertThat(mutations.requestMutations()).isEqualTo(requestMutations); + assertThat(mutations.responseMutations()).isEqualTo(responseMutations); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutatorTest.java b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutatorTest.java new file mode 100644 index 00000000000..df6ce383d8c --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutatorTest.java @@ -0,0 +1,311 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.headermutations; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableList; +import com.google.common.io.BaseEncoding; +import com.google.common.testing.TestLogHandler; +import com.google.protobuf.ByteString; +import io.envoyproxy.envoy.config.core.v3.HeaderValue; +import io.envoyproxy.envoy.config.core.v3.HeaderValueOption; +import io.envoyproxy.envoy.config.core.v3.HeaderValueOption.HeaderAppendAction; +import io.grpc.Metadata; +import io.grpc.xds.internal.headermutations.HeaderMutations.RequestHeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderMutations.ResponseHeaderMutations; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class HeaderMutatorTest { + + private static final Metadata.Key ASCII_KEY = + Metadata.Key.of("some-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key BINARY_KEY = + Metadata.Key.of("some-key-bin", Metadata.BINARY_BYTE_MARSHALLER); + private static final Metadata.Key APPEND_KEY = + Metadata.Key.of("append-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key ADD_KEY = + Metadata.Key.of("add-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key OVERWRITE_KEY = + Metadata.Key.of("overwrite-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key REMOVE_KEY = + Metadata.Key.of("remove-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key NEW_ADD_KEY = + Metadata.Key.of("new-add-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key NEW_OVERWRITE_KEY = + Metadata.Key.of("new-overwrite-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key OVERWRITE_IF_EXISTS_KEY = + Metadata.Key.of("overwrite-if-exists-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key OVERWRITE_IF_EXISTS_ABSENT_KEY = + Metadata.Key.of("overwrite-if-exists-absent-key", Metadata.ASCII_STRING_MARSHALLER); + + private final HeaderMutator headerMutator = HeaderMutator.create(); + + private static final TestLogHandler logHandler = new TestLogHandler(); + private static final Logger logger = + Logger.getLogger(HeaderMutator.HeaderMutatorImpl.class.getName()); + + @Before + public void setUp() { + logHandler.clear(); + logger.addHandler(logHandler); + logger.setLevel(Level.WARNING); + } + + @After + public void tearDown() { + logger.removeHandler(logHandler); + } + + private static HeaderValueOption header(String key, String value, HeaderAppendAction action) { + return HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey(key).setValue(value)).setAppendAction(action) + .build(); + } + + @Test + public void applyRequestMutations_asciiHeaders() { + Metadata headers = new Metadata(); + headers.put(APPEND_KEY, "append-value-1"); + headers.put(ADD_KEY, "add-value-original"); + headers.put(OVERWRITE_KEY, "overwrite-value-original"); + headers.put(REMOVE_KEY, "remove-value-original"); + headers.put(OVERWRITE_IF_EXISTS_KEY, "original-value"); + + RequestHeaderMutations mutations = RequestHeaderMutations.create(ImmutableList.of( + // Append to existing header + header(APPEND_KEY.name(), "append-value-2", HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD), + // Try to add to an existing header (should be no-op) + header(ADD_KEY.name(), "add-value-new", HeaderAppendAction.ADD_IF_ABSENT), + // Add a new header + header(NEW_ADD_KEY.name(), "new-add-value", HeaderAppendAction.ADD_IF_ABSENT), + // Overwrite an existing header + header(OVERWRITE_KEY.name(), "overwrite-value-new", + HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD), + // Overwrite a new header + header(NEW_OVERWRITE_KEY.name(), "new-overwrite-value", + HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD), + // Overwrite an existing header if it exists + header(OVERWRITE_IF_EXISTS_KEY.name(), "new-value", HeaderAppendAction.OVERWRITE_IF_EXISTS), + // Try to overwrite a header that does not exist + header(OVERWRITE_IF_EXISTS_ABSENT_KEY.name(), "new-value", + HeaderAppendAction.OVERWRITE_IF_EXISTS)), + ImmutableList.of(REMOVE_KEY.name())); + + headerMutator.applyRequestMutations(mutations, headers); + + assertThat(headers.getAll(APPEND_KEY)).containsExactly("append-value-1", "append-value-2"); + assertThat(headers.get(ADD_KEY)).isEqualTo("add-value-original"); + assertThat(headers.get(NEW_ADD_KEY)).isEqualTo("new-add-value"); + assertThat(headers.get(OVERWRITE_KEY)).isEqualTo("overwrite-value-new"); + assertThat(headers.get(NEW_OVERWRITE_KEY)).isEqualTo("new-overwrite-value"); + assertThat(headers.containsKey(REMOVE_KEY)).isFalse(); + assertThat(headers.get(OVERWRITE_IF_EXISTS_KEY)).isEqualTo("new-value"); + assertThat(headers.containsKey(OVERWRITE_IF_EXISTS_ABSENT_KEY)).isFalse(); + } + + @Test + public void applyRequestMutations_InvalidAppendAction_isIgnored() { + Metadata headers = new Metadata(); + headers.put(ASCII_KEY, "value1"); + headerMutator + .applyRequestMutations( + RequestHeaderMutations + .create( + ImmutableList.of( + HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey(ASCII_KEY.name()) + .setValue("value2")) + .setAppendActionValue(-1).build(), + HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey(BINARY_KEY.name()) + .setValue("value2")) + .setAppendActionValue(-5).build()), + ImmutableList.of()), + headers); + assertThat(headers.getAll(ASCII_KEY)).containsExactly("value1"); + } + + @Test + public void applyRequestMutations_removalHasPriority() { + Metadata headers = new Metadata(); + headers.put(REMOVE_KEY, "value"); + RequestHeaderMutations mutations = RequestHeaderMutations.create( + ImmutableList.of( + header(REMOVE_KEY.name(), "new-value", HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD)), + ImmutableList.of(REMOVE_KEY.name())); + + headerMutator.applyRequestMutations(mutations, headers); + + assertThat(headers.containsKey(REMOVE_KEY)).isFalse(); + } + + @Test + public void applyRequestMutations_binary_withBase64RawValue() { + Metadata headers = new Metadata(); + byte[] value = new byte[] {1, 2, 3}; + HeaderValueOption option = HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey(BINARY_KEY.name()).setRawValue( + ByteString.copyFrom(BaseEncoding.base64().encode(value), StandardCharsets.US_ASCII))) + .setAppendAction(HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD).build(); + headerMutator.applyRequestMutations( + RequestHeaderMutations.create(ImmutableList.of(option), ImmutableList.of()), headers); + assertThat(headers.get(BINARY_KEY)).isEqualTo(value); + } + + @Test + public void applyRequestMutations_binary_withBase64Value() { + Metadata headers = new Metadata(); + byte[] value = new byte[] {1, 2, 3}; + String base64Value = BaseEncoding.base64().encode(value); + HeaderValueOption option = HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey(BINARY_KEY.name()).setValue(base64Value)) + .setAppendAction(HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD).build(); + + headerMutator.applyRequestMutations( + RequestHeaderMutations.create(ImmutableList.of(option), ImmutableList.of()), headers); + assertThat(headers.get(BINARY_KEY)).isEqualTo(value); + } + + @Test + public void applyRequestMutations_ascii_withRawValue() { + Metadata headers = new Metadata(); + byte[] value = "raw-value".getBytes(StandardCharsets.US_ASCII); + HeaderValueOption option = HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey(ASCII_KEY.name()) + .setRawValue(ByteString.copyFrom(value))) + .setAppendAction(HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD).build(); + headerMutator.applyRequestMutations( + RequestHeaderMutations.create(ImmutableList.of(option), ImmutableList.of()), headers); + assertThat(headers.get(Metadata.Key.of(ASCII_KEY.name(), Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("raw-value"); + } + + @Test + public void applyResponseMutations_asciiHeaders() { + Metadata headers = new Metadata(); + headers.put(APPEND_KEY, "append-value-1"); + headers.put(ADD_KEY, "add-value-original"); + headers.put(OVERWRITE_KEY, "overwrite-value-original"); + + ResponseHeaderMutations mutations = ResponseHeaderMutations.create(ImmutableList.of( + header(APPEND_KEY.name(), "append-value-2", HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD), + header(ADD_KEY.name(), "add-value-new", HeaderAppendAction.ADD_IF_ABSENT), + header(NEW_ADD_KEY.name(), "new-add-value", HeaderAppendAction.ADD_IF_ABSENT), + header(OVERWRITE_KEY.name(), "overwrite-value-new", + HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD), + header(NEW_OVERWRITE_KEY.name(), "new-overwrite-value", + HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD))); + + headerMutator.applyResponseMutations(mutations, headers); + + assertThat(headers.getAll(APPEND_KEY)).containsExactly("append-value-1", "append-value-2"); + assertThat(headers.get(ADD_KEY)).isEqualTo("add-value-original"); + assertThat(headers.get(NEW_ADD_KEY)).isEqualTo("new-add-value"); + assertThat(headers.get(OVERWRITE_KEY)).isEqualTo("overwrite-value-new"); + assertThat(headers.get(NEW_OVERWRITE_KEY)).isEqualTo("new-overwrite-value"); + } + + + @Test + public void applyResponseMutations_InvalidAppendAction_isIgnored() { + Metadata headers = new Metadata(); + headers.put(ASCII_KEY, "value1"); + headerMutator + .applyResponseMutations( + ResponseHeaderMutations + .create( + ImmutableList.of( + HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey(ASCII_KEY.name()) + .setValue("value2")) + .setAppendActionValue(-1).build(), + HeaderValueOption + .newBuilder().setHeader(HeaderValue.newBuilder() + .setKey(BINARY_KEY.name()).setValue("value2")) + .setAppendActionValue(-5).build())), + headers); + assertThat(headers.getAll(ASCII_KEY)).containsExactly("value1"); + } + + @Test + public void applyResponseMutations_binary_withBase64RawValue() { + Metadata headers = new Metadata(); + byte[] value = new byte[] {1, 2, 3}; + HeaderValueOption option = HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey(BINARY_KEY.name()).setRawValue( + ByteString.copyFrom(BaseEncoding.base64().encode(value), StandardCharsets.US_ASCII))) + .setAppendAction(HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD).build(); + headerMutator.applyResponseMutations(ResponseHeaderMutations.create(ImmutableList.of(option)), + headers); + assertThat(headers.get(BINARY_KEY)).isEqualTo(value); + } + + @Test + public void applyResponseMutations_binary_withBase64Value() { + Metadata headers = new Metadata(); + byte[] value = new byte[] {1, 2, 3}; + String base64Value = BaseEncoding.base64().encode(value); + HeaderValueOption option = HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey(BINARY_KEY.name()).setValue(base64Value)) + .setAppendAction(HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD).build(); + + headerMutator.applyResponseMutations(ResponseHeaderMutations.create(ImmutableList.of(option)), + headers); + assertThat(headers.get(BINARY_KEY)).isEqualTo(value); + } + + @Test + public void applyResponseMutations_ascii_withRawValue() { + Metadata headers = new Metadata(); + byte[] value = "raw-value".getBytes(StandardCharsets.US_ASCII); + HeaderValueOption option = HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey(ASCII_KEY.name()) + .setRawValue(ByteString.copyFrom(value))) + .setAppendAction(HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD).build(); + + headerMutator.applyResponseMutations(ResponseHeaderMutations.create(ImmutableList.of(option)), + headers); + assertThat(headers.get(Metadata.Key.of(ASCII_KEY.name(), Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("raw-value"); + } + + @Test + public void applyRequestMutations_unrecognizedAction_logsWarning() { + Metadata headers = new Metadata(); + RequestHeaderMutations mutations = + RequestHeaderMutations.create(ImmutableList.of(HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey("key").setValue("value")) + .setAppendActionValue(-1).build()), ImmutableList.of()); + headerMutator.applyRequestMutations(mutations, headers); + + List records = logHandler.getStoredLogRecords(); + assertThat(records).hasSize(1); + assertThat(records.get(0).getMessage()) + .contains("Unrecognized HeaderAppendAction: UNRECOGNIZED"); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderValueOptionTest.java b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderValueOptionTest.java new file mode 100644 index 00000000000..49c43749135 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderValueOptionTest.java @@ -0,0 +1,40 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal.headermutations; + +import static com.google.common.truth.Truth.assertThat; + +import io.grpc.xds.internal.grpcservice.HeaderValue; +import io.grpc.xds.internal.headermutations.HeaderValueOption.HeaderAppendAction; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class HeaderValueOptionTest { + + @Test + public void create_withAllFields_success() { + HeaderValue header = HeaderValue.create("key1", "value1"); + HeaderValueOption option = HeaderValueOption.create( + header, HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD, true); + + assertThat(option.header()).isEqualTo(header); + assertThat(option.appendAction()).isEqualTo(HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD); + assertThat(option.keepEmptyValue()).isTrue(); + } +} From 696198967aa8e12d149e6d43e7acf0766d3e2116 Mon Sep 17 00:00:00 2001 From: Saurav Date: Thu, 12 Mar 2026 13:00:21 +0000 Subject: [PATCH 6/6] Fixup: 12494 address comments and bring back up to updated ext authz spec --- .../headermutations/HeaderMutationFilter.java | 184 +++++------- .../headermutations/HeaderMutations.java | 1 - .../headermutations/HeaderMutator.java | 164 +++++------ .../HeaderMutationFilterTest.java | 77 +++-- .../headermutations/HeaderMutationsTest.java | 16 +- .../headermutations/HeaderMutatorTest.java | 264 +++++++----------- 6 files changed, 307 insertions(+), 399 deletions(-) diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationFilter.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationFilter.java index 0452354d823..a2c6e6dc7eb 100644 --- a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationFilter.java +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationFilter.java @@ -17,12 +17,10 @@ package io.grpc.xds.internal.headermutations; import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableSet; -import io.envoyproxy.envoy.config.core.v3.HeaderValueOption; +import io.grpc.xds.internal.grpcservice.HeaderValueValidationUtils; import io.grpc.xds.internal.headermutations.HeaderMutations.RequestHeaderMutations; import io.grpc.xds.internal.headermutations.HeaderMutations.ResponseHeaderMutations; import java.util.Collection; -import java.util.Locale; import java.util.Optional; import java.util.function.Predicate; @@ -30,28 +28,14 @@ * The HeaderMutationFilter class is responsible for filtering header mutations based on a given set * of rules. */ -public interface HeaderMutationFilter { +public class HeaderMutationFilter { + private final Optional mutationRules; - /** - * A factory for creating {@link HeaderMutationFilter} instances. - */ - @FunctionalInterface - interface Factory { - /** - * Creates a new instance of {@code HeaderMutationFilter}. - * - * @param mutationRules The rules for header mutations. If an empty {@code Optional} is - * provided, all header mutations are allowed by default, except for certain system - * headers. If a {@link HeaderMutationRulesConfig} is provided, mutations will be - * filtered based on the specified rules. - */ - HeaderMutationFilter create(Optional mutationRules); - } - /** - * The default factory for creating {@link HeaderMutationFilter} instances. - */ - Factory INSTANCE = HeaderMutationFilterImpl::new; + + public HeaderMutationFilter(Optional mutationRules) { // NOPMD + this.mutationRules = mutationRules; + } /** * Filters the given header mutations based on the configured rules and returns the allowed @@ -62,111 +46,73 @@ interface Factory { * @throws HeaderMutationDisallowedException if a disallowed mutation is encountered and the rules * specify that this should be an error. */ - HeaderMutations filter(HeaderMutations mutations) throws HeaderMutationDisallowedException; - - /** Default implementation of {@link HeaderMutationFilter}. */ - final class HeaderMutationFilterImpl implements HeaderMutationFilter { - private final Optional mutationRules; - - /** - * Set of HTTP/2 pseudo-headers and the host header that are critical for routing and protocol - * correctness. These headers cannot be mutated by user configuration. - */ - private static final ImmutableSet IMMUTABLE_HEADERS = - ImmutableSet.of("host", ":authority", ":scheme", ":method"); - - private HeaderMutationFilterImpl(Optional mutationRules) { // NOPMD - this.mutationRules = mutationRules; - } - - @Override - public HeaderMutations filter(HeaderMutations mutations) - throws HeaderMutationDisallowedException { - ImmutableList allowedRequestHeaders = - filterCollection(mutations.requestMutations().headers(), - header -> isHeaderMutationAllowed(header.getHeader().getKey()) - && !appendsSystemHeader(header)); - ImmutableList allowedRequestHeadersToRemove = - filterCollection(mutations.requestMutations().headersToRemove(), - header -> isHeaderMutationAllowed(header) && isHeaderRemovalAllowed(header)); - ImmutableList allowedResponseHeaders = - filterCollection(mutations.responseMutations().headers(), - header -> isHeaderMutationAllowed(header.getHeader().getKey()) - && !appendsSystemHeader(header)); - return HeaderMutations.create( - RequestHeaderMutations.create(allowedRequestHeaders, allowedRequestHeadersToRemove), - ResponseHeaderMutations.create(allowedResponseHeaders)); - } + public HeaderMutations filter(HeaderMutations mutations) + throws HeaderMutationDisallowedException { + ImmutableList allowedRequestHeaders = + filterCollection(mutations.requestMutations().headers(), + this::shouldIgnore, this::isHeaderMutationAllowed); + ImmutableList allowedRequestHeadersToRemove = + filterCollection(mutations.requestMutations().headersToRemove(), + this::shouldIgnore, this::isHeaderMutationAllowed); + ImmutableList allowedResponseHeaders = + filterCollection(mutations.responseMutations().headers(), + this::shouldIgnore, this::isHeaderMutationAllowed); + return HeaderMutations.create( + RequestHeaderMutations.create(allowedRequestHeaders, allowedRequestHeadersToRemove), + ResponseHeaderMutations.create(allowedResponseHeaders)); + } - /** - * A generic helper to filter a collection based on a predicate. - * - * @param items The collection of items to filter. - * @param isAllowedPredicate The predicate to apply to each item. - * @param The type of items in the collection. - * @return An immutable list of allowed items. - * @throws HeaderMutationDisallowedException if an item is disallowed and disallowIsError is - * true. - */ - private ImmutableList filterCollection(Collection items, - Predicate isAllowedPredicate) throws HeaderMutationDisallowedException { - ImmutableList.Builder allowed = ImmutableList.builder(); - for (T item : items) { - if (isAllowedPredicate.test(item)) { - allowed.add(item); - } else if (disallowIsError()) { - throw new HeaderMutationDisallowedException( - "Header mutation disallowed for header: " + item); - } + /** + * A generic helper to filter a collection based on a predicate. + */ + private ImmutableList filterCollection(Collection items, + Predicate isIgnoredPredicate, Predicate isAllowedPredicate) + throws HeaderMutationDisallowedException { + ImmutableList.Builder allowed = ImmutableList.builder(); + for (T item : items) { + if (isIgnoredPredicate.test(item)) { + continue; + } + if (isAllowedPredicate.test(item)) { + allowed.add(item); + } else if (disallowIsError()) { + throw new HeaderMutationDisallowedException( + "Header mutation disallowed for header: " + item); } - return allowed.build(); } + return allowed.build(); + } - private boolean isHeaderRemovalAllowed(String headerKey) { - return !isSystemHeaderKey(headerKey); - } + private boolean shouldIgnore(String key) { + return HeaderValueValidationUtils.shouldIgnore(key); + } - private boolean appendsSystemHeader(HeaderValueOption headerValueOption) { - String key = headerValueOption.getHeader().getKey(); - boolean isAppend = headerValueOption - .getAppendAction() == HeaderValueOption.HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD; - return isAppend && isSystemHeaderKey(key); - } + private boolean shouldIgnore(HeaderValueOption option) { + return HeaderValueValidationUtils.shouldIgnore(option.header()); + } - private boolean isSystemHeaderKey(String key) { - return key.startsWith(":") || key.toLowerCase(Locale.ROOT).equals("host"); - } + private boolean isHeaderMutationAllowed(HeaderValueOption option) { + return isHeaderMutationAllowed(option.header().key()); + } - private boolean isHeaderMutationAllowed(String headerName) { - String lowerCaseHeaderName = headerName.toLowerCase(Locale.ROOT); - if (IMMUTABLE_HEADERS.contains(lowerCaseHeaderName)) { - return false; - } - return mutationRules.map(rules -> isHeaderMutationAllowed(lowerCaseHeaderName, rules)) - .orElse(true); - } + private boolean isHeaderMutationAllowed(String headerName) { + return mutationRules.map(rules -> isHeaderMutationAllowed(headerName, rules)) + .orElse(true); + } - private boolean isHeaderMutationAllowed(String lowerCaseHeaderName, - HeaderMutationRulesConfig rules) { - // TODO(sauravzg): The priority is slightly unclear in the spec. - // Both `disallowAll` and `disallow_expression` take precedence over `all other - // settings`. - // `allow_expression` takes precedence over everything except `disallow_expression`. - // This is a conflict between ordering for `allow_expression` and `disallowAll`. - // Choosing to proceed with current envoy implementation which favors `allow_expression` over - // `disallowAll`. - if (rules.disallowExpression().isPresent() - && rules.disallowExpression().get().matcher(lowerCaseHeaderName).matches()) { - return false; - } - if (rules.allowExpression().isPresent()) { - return rules.allowExpression().get().matcher(lowerCaseHeaderName).matches(); - } - return !rules.disallowAll(); + private boolean isHeaderMutationAllowed(String lowerCaseHeaderName, + HeaderMutationRulesConfig rules) { + if (rules.disallowExpression().isPresent() + && rules.disallowExpression().get().matcher(lowerCaseHeaderName).matches()) { + return false; } - - private boolean disallowIsError() { - return mutationRules.map(HeaderMutationRulesConfig::disallowIsError).orElse(false); + if (rules.allowExpression().isPresent()) { + return rules.allowExpression().get().matcher(lowerCaseHeaderName).matches(); } + return !rules.disallowAll(); + } + + private boolean disallowIsError() { + return mutationRules.map(HeaderMutationRulesConfig::disallowIsError).orElse(false); } } diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutations.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutations.java index e0cb3daede3..911d798d483 100644 --- a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutations.java +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutations.java @@ -18,7 +18,6 @@ import com.google.auto.value.AutoValue; import com.google.common.collect.ImmutableList; -import io.envoyproxy.envoy.config.core.v3.HeaderValueOption; /** A collection of header mutations for both request and response headers. */ @AutoValue diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutator.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutator.java index de5b946bbc7..a0ca2f6b76c 100644 --- a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutator.java +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutator.java @@ -16,36 +16,45 @@ package io.grpc.xds.internal.headermutations; -import com.google.common.io.BaseEncoding; -import io.envoyproxy.envoy.config.core.v3.HeaderValue; -import io.envoyproxy.envoy.config.core.v3.HeaderValueOption; -import io.envoyproxy.envoy.config.core.v3.HeaderValueOption.HeaderAppendAction; + import io.grpc.Metadata; +import io.grpc.xds.internal.grpcservice.HeaderValue; import io.grpc.xds.internal.headermutations.HeaderMutations.RequestHeaderMutations; import io.grpc.xds.internal.headermutations.HeaderMutations.ResponseHeaderMutations; -import java.nio.charset.StandardCharsets; +import io.grpc.xds.internal.headermutations.HeaderValueOption.HeaderAppendAction; import java.util.logging.Logger; /** * The HeaderMutator class is an implementation of the HeaderMutator interface. It provides methods * to apply header mutations to a given set of headers based on a given set of rules. */ -public interface HeaderMutator { +public class HeaderMutator { + + private static final Logger logger = Logger.getLogger(HeaderMutator.class.getName()); + /** * Creates a new instance of {@code HeaderMutator}. */ - static HeaderMutator create() { - return new HeaderMutatorImpl(); + public static HeaderMutator create() { + return new HeaderMutator(); } + HeaderMutator() {} + /** * Applies the given header mutations to the provided metadata headers. * * @param mutations The header mutations to apply. * @param headers The metadata headers to which the mutations will be applied. */ - void applyRequestMutations(RequestHeaderMutations mutations, Metadata headers); - + public void applyRequestMutations(final RequestHeaderMutations mutations, Metadata headers) { + // TODO(sauravzg): The specification is not clear on order of header removals and additions. + // in case of conflicts. Copying the order from Envoy here, which does removals at the end. + applyHeaderUpdates(mutations.headers(), headers); + for (String headerToRemove : mutations.headersToRemove()) { + headers.discardAll(Metadata.Key.of(headerToRemove, Metadata.ASCII_STRING_MARSHALLER)); + } + } /** * Applies the given header mutations to the provided metadata headers. @@ -53,91 +62,84 @@ static HeaderMutator create() { * @param mutations The header mutations to apply. * @param headers The metadata headers to which the mutations will be applied. */ - void applyResponseMutations(ResponseHeaderMutations mutations, Metadata headers); - - /** Default implementation of {@link HeaderMutator}. */ - final class HeaderMutatorImpl implements HeaderMutator { - - private static final Logger logger = Logger.getLogger(HeaderMutatorImpl.class.getName()); - - @Override - public void applyRequestMutations(final RequestHeaderMutations mutations, Metadata headers) { - // TODO(sauravzg): The specification is not clear on order of header removals and additions. - // in case of conflicts. Copying the order from Envoy here, which does removals at the end. - applyHeaderUpdates(mutations.headers(), headers); - for (String headerToRemove : mutations.headersToRemove()) { - headers.discardAll(Metadata.Key.of(headerToRemove, Metadata.ASCII_STRING_MARSHALLER)); - } - } + public void applyResponseMutations(final ResponseHeaderMutations mutations, Metadata headers) { + applyHeaderUpdates(mutations.headers(), headers); + } - @Override - public void applyResponseMutations(final ResponseHeaderMutations mutations, Metadata headers) { - applyHeaderUpdates(mutations.headers(), headers); + private void applyHeaderUpdates(final Iterable headerOptions, + Metadata headers) { + for (HeaderValueOption headerOption : headerOptions) { + updateHeader(headerOption, headers); } + } - private void applyHeaderUpdates(final Iterable headerOptions, - Metadata headers) { - for (HeaderValueOption headerOption : headerOptions) { - HeaderValue headerValue = headerOption.getHeader(); - updateHeader(headerValue, headerOption.getAppendAction(), headers); - } - } + private void updateHeader(final HeaderValueOption option, Metadata mutableHeaders) { + HeaderValue header = option.header(); + HeaderAppendAction action = option.appendAction(); + boolean keepEmptyValue = option.keepEmptyValue(); - private void updateHeader(final HeaderValue header, final HeaderAppendAction action, - Metadata mutableHeaders) { - if (header.getKey().endsWith(Metadata.BINARY_HEADER_SUFFIX)) { - updateHeader(action, Metadata.Key.of(header.getKey(), Metadata.BINARY_BYTE_MARSHALLER), - getBinaryHeaderValue(header), mutableHeaders); - } else { - updateHeader(action, Metadata.Key.of(header.getKey(), Metadata.ASCII_STRING_MARSHALLER), - getAsciiValue(header), mutableHeaders); - } + if (header.key().endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + updateHeader(action, Metadata.Key.of(header.key(), Metadata.BINARY_BYTE_MARSHALLER), + header.rawValue().get().toByteArray(), mutableHeaders, keepEmptyValue); + } else { + updateHeader(action, Metadata.Key.of(header.key(), Metadata.ASCII_STRING_MARSHALLER), + header.value().get(), mutableHeaders, keepEmptyValue); } + } - private void updateHeader(final HeaderAppendAction action, final Metadata.Key key, - final T value, Metadata mutableHeaders) { - switch (action) { - case APPEND_IF_EXISTS_OR_ADD: + private void updateHeader(final HeaderAppendAction action, final Metadata.Key key, + final T value, Metadata mutableHeaders, boolean keepEmptyValue) { + switch (action) { + case APPEND_IF_EXISTS_OR_ADD: + mutableHeaders.put(key, value); + break; + case ADD_IF_ABSENT: + if (!mutableHeaders.containsKey(key)) { mutableHeaders.put(key, value); - break; - case ADD_IF_ABSENT: - if (!mutableHeaders.containsKey(key)) { - mutableHeaders.put(key, value); - } - break; - case OVERWRITE_IF_EXISTS_OR_ADD: + } + break; + case OVERWRITE_IF_EXISTS_OR_ADD: + mutableHeaders.discardAll(key); + mutableHeaders.put(key, value); + break; + case OVERWRITE_IF_EXISTS: + if (mutableHeaders.containsKey(key)) { mutableHeaders.discardAll(key); mutableHeaders.put(key, value); - break; - case OVERWRITE_IF_EXISTS: - if (mutableHeaders.containsKey(key)) { - mutableHeaders.discardAll(key); - mutableHeaders.put(key, value); - } - break; - case UNRECOGNIZED: - // Ignore invalid value - logger.warning("Unrecognized HeaderAppendAction: " + action); - break; - default: - // Should be unreachable unless there's a proto schema mismatch. - logger.warning("Unknown HeaderAppendAction: " + action); - } + } + break; + + default: + // Should be unreachable unless there's a proto schema mismatch. + logger.warning("Unknown HeaderAppendAction: " + action); } - private byte[] getBinaryHeaderValue(HeaderValue header) { - return BaseEncoding.base64().decode(getAsciiValue(header)); + if (!keepEmptyValue) { + checkAndRemoveEmpty(key, mutableHeaders); } + } - private String getAsciiValue(HeaderValue header) { - // TODO(sauravzg): GRPC only supports base64 encoded binary headers, so we decode bytes to - // String using `StandardCharsets.US_ASCII`. - // Envoy's spec `raw_value` specification can contain non UTF-8 bytes, so this may potentially - // cause an exception or corruption. - if (!header.getRawValue().isEmpty()) { - return header.getRawValue().toString(StandardCharsets.US_ASCII); + private void checkAndRemoveEmpty(Metadata.Key key, Metadata headers) { + Iterable values = headers.getAll(key); + if (values == null) { + return; + } + boolean allEmpty = true; + for (T val : values) { + if (val instanceof String) { + if (!((String) val).isEmpty()) { + allEmpty = false; + break; + } + } else if (val instanceof byte[]) { + if (((byte[]) val).length > 0) { + allEmpty = false; + break; + } } - return header.getValue(); + } + if (allEmpty) { + headers.discardAll(key); } } } diff --git a/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationFilterTest.java b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationFilterTest.java index e73460924c7..41ce2245211 100644 --- a/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationFilterTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationFilterTest.java @@ -19,13 +19,11 @@ import static com.google.common.truth.Truth.assertThat; import com.google.common.collect.ImmutableList; -import io.envoyproxy.envoy.config.core.v3.HeaderValue; -import io.envoyproxy.envoy.config.core.v3.HeaderValueOption; -import io.envoyproxy.envoy.config.core.v3.HeaderValueOption.HeaderAppendAction; +import com.google.re2j.Pattern; import io.grpc.xds.internal.headermutations.HeaderMutations.RequestHeaderMutations; import io.grpc.xds.internal.headermutations.HeaderMutations.ResponseHeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderValueOption.HeaderAppendAction; import java.util.Optional; -import java.util.regex.Pattern; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @@ -34,19 +32,19 @@ public class HeaderMutationFilterTest { private static HeaderValueOption header(String key, String value) { - return HeaderValueOption.newBuilder() - .setHeader(HeaderValue.newBuilder().setKey(key).setValue(value)).build(); + return HeaderValueOption.create(io.grpc.xds.internal.grpcservice.HeaderValue.create(key, value), + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD, false); } private static HeaderValueOption header(String key, String value, HeaderAppendAction action) { - return HeaderValueOption.newBuilder() - .setHeader(HeaderValue.newBuilder().setKey(key).setValue(value)).setAppendAction(action) - .build(); + return HeaderValueOption.create(io.grpc.xds.internal.grpcservice.HeaderValue.create(key, value), + action, + false); } @Test public void filter_removesImmutableHeaders() throws HeaderMutationDisallowedException { - HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.empty()); + HeaderMutationFilter filter = new HeaderMutationFilter(Optional.empty()); HeaderMutations mutations = HeaderMutations.create( RequestHeaderMutations.create( ImmutableList.of(header("add-key", "add-value"), header(":authority", "new-authority"), @@ -66,7 +64,7 @@ public void filter_removesImmutableHeaders() throws HeaderMutationDisallowedExce @Test public void filter_cannotAppendToSystemHeaders() throws HeaderMutationDisallowedException { - HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.empty()); + HeaderMutationFilter filter = new HeaderMutationFilter(Optional.empty()); HeaderMutations mutations = HeaderMutations.create( RequestHeaderMutations.create( @@ -89,7 +87,7 @@ public void filter_cannotAppendToSystemHeaders() throws HeaderMutationDisallowed @Test public void filter_cannotRemoveSystemHeaders() throws HeaderMutationDisallowedException { - HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.empty()); + HeaderMutationFilter filter = new HeaderMutationFilter(Optional.empty()); HeaderMutations mutations = HeaderMutations.create( RequestHeaderMutations.create(ImmutableList.of(), ImmutableList.of("remove-key", "host", ":foo", ":bar")), @@ -101,9 +99,9 @@ public void filter_cannotRemoveSystemHeaders() throws HeaderMutationDisallowedEx } @Test - public void filter_canOverrideSystemHeadersNotInImmutableHeaders() + public void filter_cannotOverrideSystemHeaders() throws HeaderMutationDisallowedException { - HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.empty()); + HeaderMutationFilter filter = new HeaderMutationFilter(Optional.empty()); HeaderMutations mutations = HeaderMutations.create( RequestHeaderMutations.create( ImmutableList.of(header("user-agent", "new-agent"), @@ -115,19 +113,17 @@ public void filter_canOverrideSystemHeadersNotInImmutableHeaders() HeaderMutations filtered = filter.filter(mutations); + // System headers should be filtered out assertThat(filtered.requestMutations().headers()).containsExactly( - header("user-agent", "new-agent"), - header(":path", "/new/path", HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD), - header(":grpc-trace-bin", "binary-value", HeaderAppendAction.ADD_IF_ABSENT)); - assertThat(filtered.responseMutations().headers()) - .containsExactly(header(":alt-svc", "h3=:443", HeaderAppendAction.OVERWRITE_IF_EXISTS)); + header("user-agent", "new-agent")); + assertThat(filtered.responseMutations().headers()).isEmpty(); } @Test public void filter_disallowAll_disablesAllModifications() throws HeaderMutationDisallowedException { HeaderMutationRulesConfig rules = HeaderMutationRulesConfig.builder().disallowAll(true).build(); - HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.of(rules)); + HeaderMutationFilter filter = new HeaderMutationFilter(Optional.of(rules)); HeaderMutations mutations = HeaderMutations.create( RequestHeaderMutations.create(ImmutableList.of(header("add-key", "add-value")), ImmutableList.of("remove-key")), @@ -145,7 +141,7 @@ public void filter_disallowExpression_filtersRelevantExpressions() throws HeaderMutationDisallowedException { HeaderMutationRulesConfig rules = HeaderMutationRulesConfig.builder() .disallowExpression(Pattern.compile("^x-private-.*")).build(); - HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.of(rules)); + HeaderMutationFilter filter = new HeaderMutationFilter(Optional.of(rules)); HeaderMutations mutations = HeaderMutations.create( RequestHeaderMutations.create( ImmutableList.of(header("x-public", "value"), header("x-private-key", "value")), @@ -166,7 +162,7 @@ public void filter_allowExpression_onlyAllowsRelevantExpressions() throws HeaderMutationDisallowedException { HeaderMutationRulesConfig rules = HeaderMutationRulesConfig.builder() .allowExpression(Pattern.compile("^x-allowed-.*")).build(); - HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.of(rules)); + HeaderMutationFilter filter = new HeaderMutationFilter(Optional.of(rules)); HeaderMutations mutations = HeaderMutations.create( RequestHeaderMutations.create( @@ -190,7 +186,7 @@ public void filter_allowExpression_overridesDisallowAll() throws HeaderMutationDisallowedException { HeaderMutationRulesConfig rules = HeaderMutationRulesConfig.builder().disallowAll(true) .allowExpression(Pattern.compile("^x-allowed-.*")).build(); - HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.of(rules)); + HeaderMutationFilter filter = new HeaderMutationFilter(Optional.of(rules)); HeaderMutations mutations = HeaderMutations.create( RequestHeaderMutations.create( ImmutableList.of(header("x-allowed-key", "value"), header("not-allowed", "value")), @@ -212,7 +208,7 @@ public void filter_disallowIsError_throwsExceptionOnDisallowed() throws HeaderMutationDisallowedException { HeaderMutationRulesConfig rules = HeaderMutationRulesConfig.builder().disallowAll(true).disallowIsError(true).build(); - HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.of(rules)); + HeaderMutationFilter filter = new HeaderMutationFilter(Optional.of(rules)); HeaderMutations mutations = HeaderMutations.create(RequestHeaderMutations .create(ImmutableList.of(header("add-key", "add-value")), ImmutableList.of()), ResponseHeaderMutations.create(ImmutableList.of())); @@ -224,7 +220,7 @@ public void filter_disallowIsError_throwsExceptionOnDisallowedRemove() throws HeaderMutationDisallowedException { HeaderMutationRulesConfig rules = HeaderMutationRulesConfig.builder().disallowAll(true).disallowIsError(true).build(); - HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.of(rules)); + HeaderMutationFilter filter = new HeaderMutationFilter(Optional.of(rules)); HeaderMutations mutations = HeaderMutations.create( RequestHeaderMutations.create(ImmutableList.of(), ImmutableList.of("remove-key")), ResponseHeaderMutations.create(ImmutableList.of())); @@ -236,10 +232,39 @@ public void filter_disallowIsError_throwsExceptionOnDisallowedResponseHeader() throws HeaderMutationDisallowedException { HeaderMutationRulesConfig rules = HeaderMutationRulesConfig.builder().disallowAll(true).disallowIsError(true).build(); - HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.of(rules)); + HeaderMutationFilter filter = new HeaderMutationFilter(Optional.of(rules)); HeaderMutations mutations = HeaderMutations.create( RequestHeaderMutations.create(ImmutableList.of(), ImmutableList.of()), ResponseHeaderMutations.create(ImmutableList.of(header("resp-add-key", "resp-add-value")))); filter.filter(mutations); } + + @Test + public void filter_ignoresUppercaseHeaders() throws HeaderMutationDisallowedException { + HeaderMutationFilter filter = new HeaderMutationFilter(Optional.empty()); + HeaderMutations mutations = HeaderMutations.create( + RequestHeaderMutations.create( + ImmutableList.of(header("Valid-Key", "value"), header("valid-key", "value")), + ImmutableList.of("UPPER-REMOVE", "lower-remove")), + ResponseHeaderMutations.create(ImmutableList.of())); + + HeaderMutations filtered = filter.filter(mutations); + + assertThat(filtered.requestMutations().headers()).containsExactly(header("valid-key", "value")); + assertThat(filtered.requestMutations().headersToRemove()).containsExactly("lower-remove"); + } + + @Test + public void filter_ignoresGrpcHeadersInRemoval() throws HeaderMutationDisallowedException { + HeaderMutationFilter filter = new HeaderMutationFilter(Optional.empty()); + HeaderMutations mutations = HeaderMutations.create( + RequestHeaderMutations.create( + ImmutableList.of(), + ImmutableList.of("grpc-timeout", "valid-remove")), + ResponseHeaderMutations.create(ImmutableList.of())); + + HeaderMutations filtered = filter.filter(mutations); + + assertThat(filtered.requestMutations().headersToRemove()).containsExactly("valid-remove"); + } } diff --git a/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationsTest.java b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationsTest.java index f1dc0561692..7cd22c5eef6 100644 --- a/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationsTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationsTest.java @@ -19,26 +19,26 @@ import static com.google.common.truth.Truth.assertThat; import com.google.common.collect.ImmutableList; -import io.envoyproxy.envoy.config.core.v3.HeaderValue; -import io.envoyproxy.envoy.config.core.v3.HeaderValueOption; +import io.grpc.xds.internal.grpcservice.HeaderValue; import io.grpc.xds.internal.headermutations.HeaderMutations.RequestHeaderMutations; import io.grpc.xds.internal.headermutations.HeaderMutations.ResponseHeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderValueOption.HeaderAppendAction; import org.junit.Test; public class HeaderMutationsTest { @Test public void testCreate() { - HeaderValueOption reqHeader = HeaderValueOption.newBuilder() - .setHeader(HeaderValue.newBuilder().setKey("req-key").setValue("req-value").build()) - .build(); + HeaderValueOption reqHeader = HeaderValueOption.create( + HeaderValue.create("req-key", "req-value"), + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD, false); RequestHeaderMutations requestMutations = RequestHeaderMutations .create(ImmutableList.of(reqHeader), ImmutableList.of("remove-req-key")); assertThat(requestMutations.headers()).containsExactly(reqHeader); assertThat(requestMutations.headersToRemove()).containsExactly("remove-req-key"); - HeaderValueOption respHeader = HeaderValueOption.newBuilder() - .setHeader(HeaderValue.newBuilder().setKey("resp-key").setValue("resp-value").build()) - .build(); + HeaderValueOption respHeader = HeaderValueOption.create( + HeaderValue.create("resp-key", "resp-value"), + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD, false); ResponseHeaderMutations responseMutations = ResponseHeaderMutations.create(ImmutableList.of(respHeader)); assertThat(responseMutations.headers()).containsExactly(respHeader); diff --git a/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutatorTest.java b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutatorTest.java index df6ce383d8c..ede842d782e 100644 --- a/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutatorTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutatorTest.java @@ -19,19 +19,14 @@ import static com.google.common.truth.Truth.assertThat; import com.google.common.collect.ImmutableList; -import com.google.common.io.BaseEncoding; import com.google.common.testing.TestLogHandler; import com.google.protobuf.ByteString; -import io.envoyproxy.envoy.config.core.v3.HeaderValue; -import io.envoyproxy.envoy.config.core.v3.HeaderValueOption; -import io.envoyproxy.envoy.config.core.v3.HeaderValueOption.HeaderAppendAction; import io.grpc.Metadata; +import io.grpc.xds.internal.grpcservice.HeaderValue; import io.grpc.xds.internal.headermutations.HeaderMutations.RequestHeaderMutations; import io.grpc.xds.internal.headermutations.HeaderMutations.ResponseHeaderMutations; -import java.nio.charset.StandardCharsets; -import java.util.List; +import io.grpc.xds.internal.headermutations.HeaderValueOption.HeaderAppendAction; import java.util.logging.Level; -import java.util.logging.LogRecord; import java.util.logging.Logger; import org.junit.After; import org.junit.Before; @@ -42,8 +37,6 @@ @RunWith(JUnit4.class) public class HeaderMutatorTest { - private static final Metadata.Key ASCII_KEY = - Metadata.Key.of("some-key", Metadata.ASCII_STRING_MARSHALLER); private static final Metadata.Key BINARY_KEY = Metadata.Key.of("some-key-bin", Metadata.BINARY_BYTE_MARSHALLER); private static final Metadata.Key APPEND_KEY = @@ -66,8 +59,7 @@ public class HeaderMutatorTest { private final HeaderMutator headerMutator = HeaderMutator.create(); private static final TestLogHandler logHandler = new TestLogHandler(); - private static final Logger logger = - Logger.getLogger(HeaderMutator.HeaderMutatorImpl.class.getName()); + private static final Logger logger = Logger.getLogger(HeaderMutator.class.getName()); @Before public void setUp() { @@ -82,9 +74,7 @@ public void tearDown() { } private static HeaderValueOption header(String key, String value, HeaderAppendAction action) { - return HeaderValueOption.newBuilder() - .setHeader(HeaderValue.newBuilder().setKey(key).setValue(value)).setAppendAction(action) - .build(); + return HeaderValueOption.create(HeaderValue.create(key, value), action, false); } @Test @@ -96,25 +86,32 @@ public void applyRequestMutations_asciiHeaders() { headers.put(REMOVE_KEY, "remove-value-original"); headers.put(OVERWRITE_IF_EXISTS_KEY, "original-value"); - RequestHeaderMutations mutations = RequestHeaderMutations.create(ImmutableList.of( - // Append to existing header - header(APPEND_KEY.name(), "append-value-2", HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD), - // Try to add to an existing header (should be no-op) - header(ADD_KEY.name(), "add-value-new", HeaderAppendAction.ADD_IF_ABSENT), - // Add a new header - header(NEW_ADD_KEY.name(), "new-add-value", HeaderAppendAction.ADD_IF_ABSENT), - // Overwrite an existing header - header(OVERWRITE_KEY.name(), "overwrite-value-new", - HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD), - // Overwrite a new header - header(NEW_OVERWRITE_KEY.name(), "new-overwrite-value", - HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD), - // Overwrite an existing header if it exists - header(OVERWRITE_IF_EXISTS_KEY.name(), "new-value", HeaderAppendAction.OVERWRITE_IF_EXISTS), - // Try to overwrite a header that does not exist - header(OVERWRITE_IF_EXISTS_ABSENT_KEY.name(), "new-value", - HeaderAppendAction.OVERWRITE_IF_EXISTS)), - ImmutableList.of(REMOVE_KEY.name())); + RequestHeaderMutations mutations = + RequestHeaderMutations.create( + ImmutableList.of( + header( + APPEND_KEY.name(), + "append-value-2", + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD), + header(ADD_KEY.name(), "add-value-new", HeaderAppendAction.ADD_IF_ABSENT), + header(NEW_ADD_KEY.name(), "new-add-value", HeaderAppendAction.ADD_IF_ABSENT), + header( + OVERWRITE_KEY.name(), + "overwrite-value-new", + HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD), + header( + NEW_OVERWRITE_KEY.name(), + "new-overwrite-value", + HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD), + header( + OVERWRITE_IF_EXISTS_KEY.name(), + "new-value", + HeaderAppendAction.OVERWRITE_IF_EXISTS), + header( + OVERWRITE_IF_EXISTS_ABSENT_KEY.name(), + "new-value", + HeaderAppendAction.OVERWRITE_IF_EXISTS)), + ImmutableList.of(REMOVE_KEY.name())); headerMutator.applyRequestMutations(mutations, headers); @@ -128,36 +125,16 @@ public void applyRequestMutations_asciiHeaders() { assertThat(headers.containsKey(OVERWRITE_IF_EXISTS_ABSENT_KEY)).isFalse(); } - @Test - public void applyRequestMutations_InvalidAppendAction_isIgnored() { - Metadata headers = new Metadata(); - headers.put(ASCII_KEY, "value1"); - headerMutator - .applyRequestMutations( - RequestHeaderMutations - .create( - ImmutableList.of( - HeaderValueOption.newBuilder() - .setHeader(HeaderValue.newBuilder().setKey(ASCII_KEY.name()) - .setValue("value2")) - .setAppendActionValue(-1).build(), - HeaderValueOption.newBuilder() - .setHeader(HeaderValue.newBuilder().setKey(BINARY_KEY.name()) - .setValue("value2")) - .setAppendActionValue(-5).build()), - ImmutableList.of()), - headers); - assertThat(headers.getAll(ASCII_KEY)).containsExactly("value1"); - } - @Test public void applyRequestMutations_removalHasPriority() { Metadata headers = new Metadata(); headers.put(REMOVE_KEY, "value"); - RequestHeaderMutations mutations = RequestHeaderMutations.create( - ImmutableList.of( - header(REMOVE_KEY.name(), "new-value", HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD)), - ImmutableList.of(REMOVE_KEY.name())); + RequestHeaderMutations mutations = + RequestHeaderMutations.create( + ImmutableList.of( + header( + REMOVE_KEY.name(), "new-value", HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD)), + ImmutableList.of(REMOVE_KEY.name())); headerMutator.applyRequestMutations(mutations, headers); @@ -165,46 +142,19 @@ public void applyRequestMutations_removalHasPriority() { } @Test - public void applyRequestMutations_binary_withBase64RawValue() { + public void applyRequestMutations_binary() { Metadata headers = new Metadata(); byte[] value = new byte[] {1, 2, 3}; - HeaderValueOption option = HeaderValueOption.newBuilder() - .setHeader(HeaderValue.newBuilder().setKey(BINARY_KEY.name()).setRawValue( - ByteString.copyFrom(BaseEncoding.base64().encode(value), StandardCharsets.US_ASCII))) - .setAppendAction(HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD).build(); + HeaderValueOption option = + HeaderValueOption.create( + HeaderValue.create(BINARY_KEY.name(), ByteString.copyFrom(value)), + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD, + false); headerMutator.applyRequestMutations( RequestHeaderMutations.create(ImmutableList.of(option), ImmutableList.of()), headers); assertThat(headers.get(BINARY_KEY)).isEqualTo(value); } - @Test - public void applyRequestMutations_binary_withBase64Value() { - Metadata headers = new Metadata(); - byte[] value = new byte[] {1, 2, 3}; - String base64Value = BaseEncoding.base64().encode(value); - HeaderValueOption option = HeaderValueOption.newBuilder() - .setHeader(HeaderValue.newBuilder().setKey(BINARY_KEY.name()).setValue(base64Value)) - .setAppendAction(HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD).build(); - - headerMutator.applyRequestMutations( - RequestHeaderMutations.create(ImmutableList.of(option), ImmutableList.of()), headers); - assertThat(headers.get(BINARY_KEY)).isEqualTo(value); - } - - @Test - public void applyRequestMutations_ascii_withRawValue() { - Metadata headers = new Metadata(); - byte[] value = "raw-value".getBytes(StandardCharsets.US_ASCII); - HeaderValueOption option = HeaderValueOption.newBuilder() - .setHeader(HeaderValue.newBuilder().setKey(ASCII_KEY.name()) - .setRawValue(ByteString.copyFrom(value))) - .setAppendAction(HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD).build(); - headerMutator.applyRequestMutations( - RequestHeaderMutations.create(ImmutableList.of(option), ImmutableList.of()), headers); - assertThat(headers.get(Metadata.Key.of(ASCII_KEY.name(), Metadata.ASCII_STRING_MARSHALLER))) - .isEqualTo("raw-value"); - } - @Test public void applyResponseMutations_asciiHeaders() { Metadata headers = new Metadata(); @@ -212,14 +162,23 @@ public void applyResponseMutations_asciiHeaders() { headers.put(ADD_KEY, "add-value-original"); headers.put(OVERWRITE_KEY, "overwrite-value-original"); - ResponseHeaderMutations mutations = ResponseHeaderMutations.create(ImmutableList.of( - header(APPEND_KEY.name(), "append-value-2", HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD), - header(ADD_KEY.name(), "add-value-new", HeaderAppendAction.ADD_IF_ABSENT), - header(NEW_ADD_KEY.name(), "new-add-value", HeaderAppendAction.ADD_IF_ABSENT), - header(OVERWRITE_KEY.name(), "overwrite-value-new", - HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD), - header(NEW_OVERWRITE_KEY.name(), "new-overwrite-value", - HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD))); + ResponseHeaderMutations mutations = + ResponseHeaderMutations.create( + ImmutableList.of( + header( + APPEND_KEY.name(), + "append-value-2", + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD), + header(ADD_KEY.name(), "add-value-new", HeaderAppendAction.ADD_IF_ABSENT), + header(NEW_ADD_KEY.name(), "new-add-value", HeaderAppendAction.ADD_IF_ABSENT), + header( + OVERWRITE_KEY.name(), + "overwrite-value-new", + HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD), + header( + NEW_OVERWRITE_KEY.name(), + "new-overwrite-value", + HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD))); headerMutator.applyResponseMutations(mutations, headers); @@ -230,82 +189,59 @@ public void applyResponseMutations_asciiHeaders() { assertThat(headers.get(NEW_OVERWRITE_KEY)).isEqualTo("new-overwrite-value"); } - - @Test - public void applyResponseMutations_InvalidAppendAction_isIgnored() { - Metadata headers = new Metadata(); - headers.put(ASCII_KEY, "value1"); - headerMutator - .applyResponseMutations( - ResponseHeaderMutations - .create( - ImmutableList.of( - HeaderValueOption.newBuilder() - .setHeader(HeaderValue.newBuilder().setKey(ASCII_KEY.name()) - .setValue("value2")) - .setAppendActionValue(-1).build(), - HeaderValueOption - .newBuilder().setHeader(HeaderValue.newBuilder() - .setKey(BINARY_KEY.name()).setValue("value2")) - .setAppendActionValue(-5).build())), - headers); - assertThat(headers.getAll(ASCII_KEY)).containsExactly("value1"); - } - @Test - public void applyResponseMutations_binary_withBase64RawValue() { + public void applyResponseMutations_binary() { Metadata headers = new Metadata(); byte[] value = new byte[] {1, 2, 3}; - HeaderValueOption option = HeaderValueOption.newBuilder() - .setHeader(HeaderValue.newBuilder().setKey(BINARY_KEY.name()).setRawValue( - ByteString.copyFrom(BaseEncoding.base64().encode(value), StandardCharsets.US_ASCII))) - .setAppendAction(HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD).build(); - headerMutator.applyResponseMutations(ResponseHeaderMutations.create(ImmutableList.of(option)), - headers); + HeaderValueOption option = + HeaderValueOption.create( + HeaderValue.create(BINARY_KEY.name(), ByteString.copyFrom(value)), + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD, + false); + headerMutator.applyResponseMutations( + ResponseHeaderMutations.create(ImmutableList.of(option)), headers); assertThat(headers.get(BINARY_KEY)).isEqualTo(value); } @Test - public void applyResponseMutations_binary_withBase64Value() { + public void applyRequestMutations_keepEmptyValue() { Metadata headers = new Metadata(); - byte[] value = new byte[] {1, 2, 3}; - String base64Value = BaseEncoding.base64().encode(value); - HeaderValueOption option = HeaderValueOption.newBuilder() - .setHeader(HeaderValue.newBuilder().setKey(BINARY_KEY.name()).setValue(base64Value)) - .setAppendAction(HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD).build(); + headers.put(APPEND_KEY, "existing-value"); + headers.put(OVERWRITE_KEY, "existing-value"); - headerMutator.applyResponseMutations(ResponseHeaderMutations.create(ImmutableList.of(option)), - headers); - assertThat(headers.get(BINARY_KEY)).isEqualTo(value); - } + RequestHeaderMutations mutations = + RequestHeaderMutations.create( + ImmutableList.of( + header(NEW_ADD_KEY.name(), "", HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD), + header(APPEND_KEY.name(), "", HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD), + header(OVERWRITE_KEY.name(), "", HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD), + HeaderValueOption.create( + HeaderValue.create("keep-empty-key", ""), + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD, + true), + HeaderValueOption.create( + HeaderValue.create("keep-empty-overwrite-key", ""), + HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD, + true)), + ImmutableList.of()); + + headers.put( + Metadata.Key.of("keep-empty-overwrite-key", Metadata.ASCII_STRING_MARSHALLER), "old"); - @Test - public void applyResponseMutations_ascii_withRawValue() { - Metadata headers = new Metadata(); - byte[] value = "raw-value".getBytes(StandardCharsets.US_ASCII); - HeaderValueOption option = HeaderValueOption.newBuilder() - .setHeader(HeaderValue.newBuilder().setKey(ASCII_KEY.name()) - .setRawValue(ByteString.copyFrom(value))) - .setAppendAction(HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD).build(); + headerMutator.applyRequestMutations(mutations, headers); - headerMutator.applyResponseMutations(ResponseHeaderMutations.create(ImmutableList.of(option)), - headers); - assertThat(headers.get(Metadata.Key.of(ASCII_KEY.name(), Metadata.ASCII_STRING_MARSHALLER))) - .isEqualTo("raw-value"); - } + assertThat(headers.containsKey(NEW_ADD_KEY)).isFalse(); + assertThat(headers.getAll(APPEND_KEY)).containsExactly("existing-value", ""); + assertThat(headers.containsKey(OVERWRITE_KEY)).isFalse(); - @Test - public void applyRequestMutations_unrecognizedAction_logsWarning() { - Metadata headers = new Metadata(); - RequestHeaderMutations mutations = - RequestHeaderMutations.create(ImmutableList.of(HeaderValueOption.newBuilder() - .setHeader(HeaderValue.newBuilder().setKey("key").setValue("value")) - .setAppendActionValue(-1).build()), ImmutableList.of()); - headerMutator.applyRequestMutations(mutations, headers); + Metadata.Key keepEmptyKey = + Metadata.Key.of("keep-empty-key", Metadata.ASCII_STRING_MARSHALLER); + Metadata.Key keepEmptyOverwriteKey = + Metadata.Key.of("keep-empty-overwrite-key", Metadata.ASCII_STRING_MARSHALLER); - List records = logHandler.getStoredLogRecords(); - assertThat(records).hasSize(1); - assertThat(records.get(0).getMessage()) - .contains("Unrecognized HeaderAppendAction: UNRECOGNIZED"); + assertThat(headers.containsKey(keepEmptyKey)).isTrue(); + assertThat(headers.get(keepEmptyKey)).isEqualTo(""); + assertThat(headers.containsKey(keepEmptyOverwriteKey)).isTrue(); + assertThat(headers.get(keepEmptyOverwriteKey)).isEqualTo(""); } }