diff --git a/xds/src/main/java/io/grpc/xds/BootstrapInfoGrpcServiceContextProvider.java b/xds/src/main/java/io/grpc/xds/BootstrapInfoGrpcServiceContextProvider.java new file mode 100644 index 00000000000..864108ff431 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/BootstrapInfoGrpcServiceContextProvider.java @@ -0,0 +1,73 @@ +/* + * 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; + +import io.grpc.NameResolverRegistry; +import io.grpc.xds.client.Bootstrapper.BootstrapInfo; +import io.grpc.xds.client.Bootstrapper.ServerInfo; +import io.grpc.xds.internal.grpcservice.AllowedGrpcService; +import io.grpc.xds.internal.grpcservice.AllowedGrpcServices; +import io.grpc.xds.internal.grpcservice.GrpcServiceXdsContext; +import io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextProvider; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Optional; + +/** + * Concrete implementation of {@link GrpcServiceXdsContextProvider} that uses + * {@link BootstrapInfo} data to resolve context. + */ +final class BootstrapInfoGrpcServiceContextProvider + implements GrpcServiceXdsContextProvider { + + private final boolean isTrustedControlPlane; + private final AllowedGrpcServices allowedGrpcServices; + private final NameResolverRegistry nameResolverRegistry; + + BootstrapInfoGrpcServiceContextProvider(BootstrapInfo bootstrapInfo, ServerInfo serverInfo) { + this.isTrustedControlPlane = serverInfo.isTrustedXdsServer(); + this.allowedGrpcServices = bootstrapInfo.allowedGrpcServices() + .filter(AllowedGrpcServices.class::isInstance) + .map(AllowedGrpcServices.class::cast) + .orElse(AllowedGrpcServices.empty()); + this.nameResolverRegistry = NameResolverRegistry.getDefaultRegistry(); + } + + @Override + public GrpcServiceXdsContext getContextForTarget(String targetUri) { + Optional validAllowedGrpcService = + Optional.ofNullable(allowedGrpcServices.services().get(targetUri)); + + boolean isTargetUriSchemeSupported = false; + try { + URI uri = new URI(targetUri); + String scheme = uri.getScheme(); + if (scheme != null) { + isTargetUriSchemeSupported = + nameResolverRegistry.getProviderForScheme(scheme) != null; + } + } catch (URISyntaxException e) { + // Fallback or ignore if not a valid URI + } + + return GrpcServiceXdsContext.create( + isTrustedControlPlane, + validAllowedGrpcService, + isTargetUriSchemeSupported + ); + } +} diff --git a/xds/src/main/java/io/grpc/xds/FaultFilter.java b/xds/src/main/java/io/grpc/xds/FaultFilter.java index 0f3bb5b0557..e0533889d74 100644 --- a/xds/src/main/java/io/grpc/xds/FaultFilter.java +++ b/xds/src/main/java/io/grpc/xds/FaultFilter.java @@ -104,7 +104,8 @@ public FaultFilter newInstance(String name) { } @Override - public ConfigOrError parseFilterConfig(Message rawProtoMessage) { + public ConfigOrError parseFilterConfig( + Message rawProtoMessage, FilterContext context) { HTTPFault httpFaultProto; if (!(rawProtoMessage instanceof Any)) { return ConfigOrError.fromError("Invalid config type: " + rawProtoMessage.getClass()); @@ -119,8 +120,9 @@ public ConfigOrError parseFilterConfig(Message rawProtoMessage) { } @Override - public ConfigOrError parseFilterConfigOverride(Message rawProtoMessage) { - return parseFilterConfig(rawProtoMessage); + public ConfigOrError parseFilterConfigOverride( + Message rawProtoMessage, FilterContext context) { + return parseFilterConfig(rawProtoMessage, context); } private static ConfigOrError parseHttpFault(HTTPFault httpFault) { diff --git a/xds/src/main/java/io/grpc/xds/Filter.java b/xds/src/main/java/io/grpc/xds/Filter.java index 416d929becf..0fa5b8af128 100644 --- a/xds/src/main/java/io/grpc/xds/Filter.java +++ b/xds/src/main/java/io/grpc/xds/Filter.java @@ -16,10 +16,12 @@ package io.grpc.xds; + import com.google.common.base.MoreObjects; import com.google.protobuf.Message; import io.grpc.ClientInterceptor; import io.grpc.ServerInterceptor; +import io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextProvider; import java.io.Closeable; import java.util.Objects; import java.util.concurrent.ScheduledExecutorService; @@ -93,13 +95,15 @@ default boolean isServerFilter() { * Parses the top-level filter config from raw proto message. The message may be either a {@link * com.google.protobuf.Any} or a {@link com.google.protobuf.Struct}. */ - ConfigOrError parseFilterConfig(Message rawProtoMessage); + ConfigOrError parseFilterConfig( + Message rawProtoMessage, FilterContext context); /** * Parses the per-filter override filter config from raw proto message. The message may be * either a {@link com.google.protobuf.Any} or a {@link com.google.protobuf.Struct}. */ - ConfigOrError parseFilterConfigOverride(Message rawProtoMessage); + ConfigOrError parseFilterConfigOverride( + Message rawProtoMessage, FilterContext context); } /** Uses the FilterConfigs produced above to produce an HTTP filter interceptor for clients. */ @@ -125,6 +129,25 @@ default ServerInterceptor buildServerInterceptor( @Override default void close() {} + /** Context carrying dynamic metadata for a filter. */ + @com.google.auto.value.AutoValue + abstract class FilterContext { + public abstract GrpcServiceXdsContextProvider grpcServiceContextProvider(); + + public static Builder builder() { + return new AutoValue_Filter_FilterContext.Builder(); + } + + + @com.google.auto.value.AutoValue.Builder + public abstract static class Builder { + public abstract Builder grpcServiceContextProvider( + GrpcServiceXdsContextProvider provider); + + public abstract FilterContext build(); + } + } + /** Filter config with instance name. */ final class NamedFilterConfig { // filter instance name diff --git a/xds/src/main/java/io/grpc/xds/GcpAuthenticationFilter.java b/xds/src/main/java/io/grpc/xds/GcpAuthenticationFilter.java index 8ec02f4f809..78d20edec46 100644 --- a/xds/src/main/java/io/grpc/xds/GcpAuthenticationFilter.java +++ b/xds/src/main/java/io/grpc/xds/GcpAuthenticationFilter.java @@ -86,7 +86,8 @@ public GcpAuthenticationFilter newInstance(String name) { } @Override - public ConfigOrError parseFilterConfig(Message rawProtoMessage) { + public ConfigOrError parseFilterConfig( + Message rawProtoMessage, FilterContext context) { GcpAuthnFilterConfig gcpAuthnProto; if (!(rawProtoMessage instanceof Any)) { return ConfigOrError.fromError("Invalid config type: " + rawProtoMessage.getClass()); @@ -121,8 +122,8 @@ public ConfigOrError parseFilterConfig(Message rawProto @Override public ConfigOrError parseFilterConfigOverride( - Message rawProtoMessage) { - return parseFilterConfig(rawProtoMessage); + Message rawProtoMessage, FilterContext context) { + return parseFilterConfig(rawProtoMessage, context); } } diff --git a/xds/src/main/java/io/grpc/xds/GrpcBootstrapperImpl.java b/xds/src/main/java/io/grpc/xds/GrpcBootstrapperImpl.java index 494e95a58f6..3dd3c5c5885 100644 --- a/xds/src/main/java/io/grpc/xds/GrpcBootstrapperImpl.java +++ b/xds/src/main/java/io/grpc/xds/GrpcBootstrapperImpl.java @@ -19,14 +19,20 @@ 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.AllowedGrpcService; +import io.grpc.xds.internal.grpcservice.AllowedGrpcServices; +import io.grpc.xds.internal.grpcservice.ChannelCredsConfig; +import io.grpc.xds.internal.grpcservice.ConfiguredChannelCredentials; 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 +103,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 +127,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 +162,93 @@ private static ChannelCredentials parseChannelCredentials(List> j config = ImmutableMap.of(); } - return provider.newChannelCredentials(config); + ChannelCredentials creds = provider.newChannelCredentials(config); + if (creds == null) { + return null; + } + return ConfiguredChannelCredentials.create(creds, new JsonChannelCredsConfig(type, config)); } } return null; } + + @Override + protected Optional parseAllowedGrpcServices( + @Nullable Map rawAllowedGrpcServices) + throws XdsInitializationException { + if (rawAllowedGrpcServices == null || rawAllowedGrpcServices.isEmpty()) { + return Optional.of(AllowedGrpcServices.empty()); + } + + 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); + } + + AllowedGrpcService.Builder b = AllowedGrpcService.builder() + .configuredChannelCredentials(configuredChannel); + callCredentials.ifPresent(b::callCredentials); + builder.put(targetUri, b.build()); + } + return Optional.of(AllowedGrpcServices.create(builder.build())); + } + + @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/RbacFilter.java b/xds/src/main/java/io/grpc/xds/RbacFilter.java index 91df1e68802..035bfd06607 100644 --- a/xds/src/main/java/io/grpc/xds/RbacFilter.java +++ b/xds/src/main/java/io/grpc/xds/RbacFilter.java @@ -94,7 +94,8 @@ public RbacFilter newInstance(String name) { } @Override - public ConfigOrError parseFilterConfig(Message rawProtoMessage) { + public ConfigOrError parseFilterConfig( + Message rawProtoMessage, FilterContext context) { RBAC rbacProto; if (!(rawProtoMessage instanceof Any)) { return ConfigOrError.fromError("Invalid config type: " + rawProtoMessage.getClass()); @@ -109,7 +110,8 @@ public ConfigOrError parseFilterConfig(Message rawProtoMessage) { } @Override - public ConfigOrError parseFilterConfigOverride(Message rawProtoMessage) { + public ConfigOrError parseFilterConfigOverride( + Message rawProtoMessage, FilterContext context) { RBACPerRoute rbacPerRoute; if (!(rawProtoMessage instanceof Any)) { return ConfigOrError.fromError("Invalid config type: " + rawProtoMessage.getClass()); diff --git a/xds/src/main/java/io/grpc/xds/RouterFilter.java b/xds/src/main/java/io/grpc/xds/RouterFilter.java index 504c4213149..c80e57c9010 100644 --- a/xds/src/main/java/io/grpc/xds/RouterFilter.java +++ b/xds/src/main/java/io/grpc/xds/RouterFilter.java @@ -61,13 +61,14 @@ public RouterFilter newInstance(String name) { } @Override - public ConfigOrError parseFilterConfig(Message rawProtoMessage) { + public ConfigOrError parseFilterConfig( + Message rawProtoMessage, FilterContext context) { return ConfigOrError.fromConfig(ROUTER_CONFIG); } @Override public ConfigOrError parseFilterConfigOverride( - Message rawProtoMessage) { + Message rawProtoMessage, FilterContext context) { return ConfigOrError.fromError("Router Filter should not have override config"); } } diff --git a/xds/src/main/java/io/grpc/xds/XdsListenerResource.java b/xds/src/main/java/io/grpc/xds/XdsListenerResource.java index 041b659b4c3..4aff4a7f2ad 100644 --- a/xds/src/main/java/io/grpc/xds/XdsListenerResource.java +++ b/xds/src/main/java/io/grpc/xds/XdsListenerResource.java @@ -527,7 +527,7 @@ static io.grpc.xds.HttpConnectionManager parseHttpConnectionManager( "HttpConnectionManager contains duplicate HttpFilter: " + filterName); } StructOrError filterConfig = - parseHttpFilter(httpFilter, filterRegistry, isForClient); + parseHttpFilter(httpFilter, filterRegistry, isForClient, args); if ((i == proto.getHttpFiltersCount() - 1) && (filterConfig == null || !isTerminalFilter(filterConfig.getStruct()))) { throw new ResourceInvalidException("The last HttpFilter must be a terminal filter: " @@ -581,7 +581,8 @@ private static boolean isTerminalFilter(Filter.FilterConfig filterConfig) { @Nullable // Returns null if the filter is optional but not supported. static StructOrError parseHttpFilter( io.envoyproxy.envoy.extensions.filters.network.http_connection_manager.v3.HttpFilter - httpFilter, FilterRegistry filterRegistry, boolean isForClient) { + httpFilter, FilterRegistry filterRegistry, boolean isForClient, + XdsResourceType.Args args) { String filterName = httpFilter.getName(); boolean isOptional = httpFilter.getIsOptional(); if (!httpFilter.hasTypedConfig()) { @@ -616,7 +617,14 @@ static StructOrError parseHttpFilter( "HttpFilter [" + filterName + "](" + typeUrl + ") is required but unsupported for " + ( isForClient ? "client" : "server")); } - ConfigOrError filterConfig = provider.parseFilterConfig(rawConfig); + + BootstrapInfoGrpcServiceContextProvider contextProvider = + new BootstrapInfoGrpcServiceContextProvider(args.getBootstrapInfo(), args.getServerInfo()); + Filter.FilterContext filterContext = Filter.FilterContext.builder() + .grpcServiceContextProvider(contextProvider) + .build(); + ConfigOrError filterConfig = + provider.parseFilterConfig(rawConfig, filterContext); if (filterConfig.errorDetail != null) { return StructOrError.fromError( "Invalid filter config for HttpFilter [" + filterName + "]: " + filterConfig.errorDetail); diff --git a/xds/src/main/java/io/grpc/xds/XdsRouteConfigureResource.java b/xds/src/main/java/io/grpc/xds/XdsRouteConfigureResource.java index 24ec0659b42..0bb0c48cd65 100644 --- a/xds/src/main/java/io/grpc/xds/XdsRouteConfigureResource.java +++ b/xds/src/main/java/io/grpc/xds/XdsRouteConfigureResource.java @@ -198,7 +198,7 @@ private static StructOrError parseVirtualHost( routes.add(route.getStruct()); } StructOrError> overrideConfigs = - parseOverrideFilterConfigs(proto.getTypedPerFilterConfigMap(), filterRegistry); + parseOverrideFilterConfigs(proto.getTypedPerFilterConfigMap(), filterRegistry, args); if (overrideConfigs.getErrorDetail() != null) { return StructOrError.fromError( "VirtualHost [" + proto.getName() + "] contains invalid HttpFilter config: " @@ -210,7 +210,13 @@ private static StructOrError parseVirtualHost( @VisibleForTesting static StructOrError> parseOverrideFilterConfigs( - Map rawFilterConfigMap, FilterRegistry filterRegistry) { + Map rawFilterConfigMap, FilterRegistry filterRegistry, + XdsResourceType.Args args) { + BootstrapInfoGrpcServiceContextProvider grpcServiceContextProvider = + new BootstrapInfoGrpcServiceContextProvider(args.getBootstrapInfo(), args.getServerInfo()); + Filter.FilterContext context = Filter.FilterContext.builder() + .grpcServiceContextProvider(grpcServiceContextProvider) + .build(); Map overrideConfigs = new HashMap<>(); for (String name : rawFilterConfigMap.keySet()) { Any anyConfig = rawFilterConfigMap.get(name); @@ -254,7 +260,7 @@ static StructOrError> parseOverrideFilterConfigs( "HttpFilter [" + name + "](" + typeUrl + ") is required but unsupported"); } ConfigOrError filterConfig = - provider.parseFilterConfigOverride(rawConfig); + provider.parseFilterConfigOverride(rawConfig, context); if (filterConfig.errorDetail != null) { return StructOrError.fromError( "Invalid filter config for HttpFilter [" + name + "]: " + filterConfig.errorDetail); @@ -281,7 +287,7 @@ static StructOrError parseRoute( } StructOrError> overrideConfigsOrError = - parseOverrideFilterConfigs(proto.getTypedPerFilterConfigMap(), filterRegistry); + parseOverrideFilterConfigs(proto.getTypedPerFilterConfigMap(), filterRegistry, args); if (overrideConfigsOrError.getErrorDetail() != null) { return StructOrError.fromError( "Route [" + proto.getName() + "] contains invalid HttpFilter config: " @@ -490,7 +496,7 @@ static StructOrError parseRouteAction( for (io.envoyproxy.envoy.config.route.v3.WeightedCluster.ClusterWeight clusterWeight : clusterWeights) { StructOrError clusterWeightOrError = - parseClusterWeight(clusterWeight, filterRegistry); + parseClusterWeight(clusterWeight, filterRegistry, args); if (clusterWeightOrError.getErrorDetail() != null) { return StructOrError.fromError("RouteAction contains invalid ClusterWeight: " + clusterWeightOrError.getErrorDetail()); @@ -599,9 +605,9 @@ private static StructOrError parseRet @VisibleForTesting static StructOrError parseClusterWeight( io.envoyproxy.envoy.config.route.v3.WeightedCluster.ClusterWeight proto, - FilterRegistry filterRegistry) { + FilterRegistry filterRegistry, XdsResourceType.Args args) { StructOrError> overrideConfigs = - parseOverrideFilterConfigs(proto.getTypedPerFilterConfigMap(), filterRegistry); + parseOverrideFilterConfigs(proto.getTypedPerFilterConfigMap(), filterRegistry, args); if (overrideConfigs.getErrorDetail() != null) { return StructOrError.fromError( "ClusterWeight [" + proto.getName() + "] contains invalid HttpFilter 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..b348b927675 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,11 +206,18 @@ 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() .clientDefaultListenerResourceNameTemplate("%s") - .authorities(ImmutableMap.of()); + .authorities(ImmutableMap.of()) + .allowedGrpcServices(Optional.empty()); } @AutoValue.Builder @@ -231,7 +239,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..37fe5a5ee37 100644 --- a/xds/src/main/java/io/grpc/xds/client/BootstrapperImpl.java +++ b/xds/src/main/java/io/grpc/xds/client/BootstrapperImpl.java @@ -34,6 +34,8 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; +import javax.annotation.Nullable; /** * A {@link Bootstrapper} implementation that reads xDS configurations from local file system. @@ -239,9 +241,18 @@ protected BootstrapInfo.Builder bootstrapBuilder(Map rawData) builder.authorities(authorityInfoMapBuilder.buildOrThrow()); } + Map rawAllowedGrpcServices = JsonUtil.getObject(rawData, "allowed_grpc_services"); + builder.allowedGrpcServices(parseAllowedGrpcServices(rawAllowedGrpcServices)); + return builder; } + protected Optional parseAllowedGrpcServices( + @Nullable Map rawAllowedGrpcServices) + throws XdsInitializationException { + return 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/extauthz/ExtAuthzConfig.java b/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzConfig.java new file mode 100644 index 00000000000..5aeb44c6e2a --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzConfig.java @@ -0,0 +1,145 @@ +/* + * 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.grpc.Status; +import io.grpc.xds.internal.Matchers; +import io.grpc.xds.internal.grpcservice.GrpcServiceConfig; +import io.grpc.xds.internal.headermutations.HeaderMutationRulesConfig; +import java.util.Optional; + +/** + * 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)); + } + + /** + * 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 empty, 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(); + } +} 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..bd0f28aca0e --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzConfigParser.java @@ -0,0 +1,101 @@ +/* + * 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.HeaderMutationRulesParseException; +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.builder().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()) { + try { + builder.decoderHeaderMutationRules( + HeaderMutationRulesParser.parse(extAuthzProto.getDecoderHeaderMutationRules())); + } catch (HeaderMutationRulesParseException e) { + throw new ExtAuthzParseException(e.getMessage(), e); + } + } + + return builder.build(); + } +} 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/AllowedGrpcService.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/AllowedGrpcService.java new file mode 100644 index 00000000000..ca2f548ed4d --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/AllowedGrpcService.java @@ -0,0 +1,44 @@ +/* + * 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 com.google.auto.value.AutoValue; +import io.grpc.CallCredentials; +import java.util.Optional; + +/** + * Represents an allowed gRPC service configuration with local credentials. + */ +@AutoValue +public abstract class AllowedGrpcService { + public abstract ConfiguredChannelCredentials configuredChannelCredentials(); + + public abstract Optional callCredentials(); + + public static Builder builder() { + return new AutoValue_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/AllowedGrpcServices.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/AllowedGrpcServices.java new file mode 100644 index 00000000000..71213305888 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/AllowedGrpcServices.java @@ -0,0 +1,37 @@ +/* + * 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 com.google.auto.value.AutoValue; +import com.google.common.collect.ImmutableMap; +import java.util.Map; + +/** + * Wrapper for allowed gRPC services keyed by target URI. + */ +@AutoValue +public abstract class AllowedGrpcServices { + public abstract ImmutableMap services(); + + public static AllowedGrpcServices create(Map services) { + return new AutoValue_AllowedGrpcServices(ImmutableMap.copyOf(services)); + } + + public static AllowedGrpcServices empty() { + return create(ImmutableMap.of()); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/grpcservice/ChannelCredsConfig.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/ChannelCredsConfig.java new file mode 100644 index 00000000000..1e7008ca8e2 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/ChannelCredsConfig.java @@ -0,0 +1,27 @@ +/* + * 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; + +/** + * Configuration for channel credentials. + */ +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 new file mode 100644 index 00000000000..57df9aa0f10 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfig.java @@ -0,0 +1,88 @@ +/* + * 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.common.collect.ImmutableList; +import io.grpc.CallCredentials; +import java.time.Duration; +import java.util.Optional; + + +/** + * 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() { + return new AutoValue_GrpcServiceConfig.Builder(); + } + + public abstract GoogleGrpcConfig googleGrpc(); + + public abstract Optional timeout(); + + public abstract ImmutableList initialMetadata(); + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder googleGrpc(GoogleGrpcConfig googleGrpc); + + public abstract Builder timeout(Duration timeout); + + public abstract Builder initialMetadata(ImmutableList 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. + */ + @AutoValue + public abstract static class GoogleGrpcConfig { + + public static Builder builder() { + return new AutoValue_GrpcServiceConfig_GoogleGrpcConfig.Builder(); + } + + public abstract String target(); + + public abstract ConfiguredChannelCredentials configuredChannelCredentials(); + + public abstract Optional callCredentials(); + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder target(String target); + + public abstract Builder configuredChannelCredentials( + ConfiguredChannelCredentials channelCredentials); + + public abstract Builder callCredentials(CallCredentials callCredentials); + + public abstract GoogleGrpcConfig build(); + } + } + + +} 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..59f48a390da --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigParser.java @@ -0,0 +1,307 @@ +/* + * 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 com.google.protobuf.util.Durations; +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.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; +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.builder().googleGrpc(googleGrpcConfig); + + ImmutableList.Builder initialMetadata = ImmutableList.builder(); + for (io.envoyproxy.envoy.config.core.v3.HeaderValue header : grpcServiceProto + .getInitialMetadataList()) { + String key = header.getKey(); + HeaderValue headerValue; + if (key.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + headerValue = HeaderValue.create(key, header.getRawValue()); + } else { + headerValue = HeaderValue.create(key, header.getValue()); + } + if (HeaderValueValidationUtils.isDisallowed(headerValue)) { + throw new GrpcServiceParseException("Invalid initial metadata header: " + key); + } + initialMetadata.add(headerValue); + } + builder.initialMetadata(initialMetadata.build()); + + if (grpcServiceProto.hasTimeout()) { + com.google.protobuf.Duration timeout = grpcServiceProto.getTimeout(); + if (!Durations.isValid(timeout) || Durations.compare(timeout, Durations.ZERO) <= 0) { + throw new GrpcServiceParseException("Timeout must be strictly positive and valid"); + } + 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 = + extractChannelCredentials(googleGrpcProto.getChannelCredentialsPluginList()); + + 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) { + delegate.applyRequestMetadata(requestInfo, appExecutor, applier); + } else { + applier.apply(new Metadata()); + } + } + } + + 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/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/GrpcServiceXdsContext.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceXdsContext.java new file mode 100644 index 00000000000..424d18fc34a --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceXdsContext.java @@ -0,0 +1,47 @@ +/* + * 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.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); + } + +} 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/HeaderValueValidationUtils.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/HeaderValueValidationUtils.java new file mode 100644 index 00000000000..ff0df11bdc5 --- /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 is disallowed for mutations or validation. + * + * @param key The header key (e.g., "content-type") + */ + public static boolean isDisallowed(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 is disallowed. + * + * @param header The HeaderValue containing key and values + */ + public static boolean isDisallowed(HeaderValue header) { + if (isDisallowed(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/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..b16ec7948ed --- /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 com.google.re2j.Pattern; +import io.envoyproxy.envoy.config.common.mutation_rules.v3.HeaderMutationRules; +import java.util.Optional; + +/** + * 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, a disallowed header mutation will result in an error instead of being ignored. + * + * @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/main/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesParseException.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesParseException.java new file mode 100644 index 00000000000..3782e84a54b --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesParseException.java @@ -0,0 +1,32 @@ +/* + * 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; + +/** + * Exception thrown when parsing header mutation rules fails. + */ +public final class HeaderMutationRulesParseException extends Exception { + private static final long serialVersionUID = 1L; + + public HeaderMutationRulesParseException(String message) { + super(message); + } + + public HeaderMutationRulesParseException(String message, Throwable cause) { + super(message, cause); + } +} 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..f6bb2ec508d --- /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; + +/** + * 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 HeaderMutationRulesParseException { + 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 HeaderMutationRulesParseException { + try { + return Pattern.compile(regex); + } catch (PatternSyntaxException e) { + throw new HeaderMutationRulesParseException( + "Invalid regex pattern for " + fieldName + ": " + e.getMessage(), e); + } + } +} diff --git a/xds/src/test/java/io/grpc/xds/BootstrapInfoGrpcServiceContextProviderTest.java b/xds/src/test/java/io/grpc/xds/BootstrapInfoGrpcServiceContextProviderTest.java new file mode 100644 index 00000000000..ab42f634daa --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/BootstrapInfoGrpcServiceContextProviderTest.java @@ -0,0 +1,139 @@ +/* + * 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; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableList; +import io.grpc.ChannelCredentials; +import io.grpc.InsecureChannelCredentials; +import io.grpc.xds.client.Bootstrapper.BootstrapInfo; +import io.grpc.xds.client.Bootstrapper.ServerInfo; +import io.grpc.xds.client.EnvoyProtoData; +import io.grpc.xds.internal.grpcservice.AllowedGrpcService; +import io.grpc.xds.internal.grpcservice.AllowedGrpcServices; +import io.grpc.xds.internal.grpcservice.ChannelCredsConfig; +import io.grpc.xds.internal.grpcservice.ConfiguredChannelCredentials; +import io.grpc.xds.internal.grpcservice.GrpcServiceXdsContext; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Unit tests for {@link BootstrapInfoGrpcServiceContextProvider}. + */ +@RunWith(JUnit4.class) +public class BootstrapInfoGrpcServiceContextProviderTest { + + private static final ChannelCredentials CREDENTIALS = InsecureChannelCredentials.create(); + private static final ChannelCredsConfig DUMMY_CONFIG = () -> "dummy"; + private static final EnvoyProtoData.Node DUMMY_NODE = + EnvoyProtoData.Node.newBuilder().setId("node-id").build(); + + private static final BootstrapInfo DUMMY_BOOTSTRAP = BootstrapInfo.builder() + .servers(ImmutableList.of()) + .node(DUMMY_NODE) + .build(); + + private static ServerInfo createServerInfo(boolean isTrusted) { + return ServerInfo.create("xds:///any", CREDENTIALS, false, isTrusted, false, false); + } + + @Test + public void getContextForTarget_trustedServer() { + ServerInfo serverInfo = createServerInfo(true); + BootstrapInfoGrpcServiceContextProvider provider = + new BootstrapInfoGrpcServiceContextProvider(DUMMY_BOOTSTRAP, serverInfo); + + GrpcServiceXdsContext context = provider.getContextForTarget("xds:///any"); + assertThat(context.isTrustedControlPlane()).isTrue(); + } + + @Test + public void getContextForTarget_untrustedServer() { + ServerInfo serverInfo = createServerInfo(false); + BootstrapInfoGrpcServiceContextProvider provider = + new BootstrapInfoGrpcServiceContextProvider(DUMMY_BOOTSTRAP, serverInfo); + + GrpcServiceXdsContext context = provider.getContextForTarget("xds:///any"); + assertThat(context.isTrustedControlPlane()).isFalse(); + } + + @Test + public void getContextForTarget_allowedGrpcServices() { + ConfiguredChannelCredentials creds = ConfiguredChannelCredentials.create( + CREDENTIALS, DUMMY_CONFIG); + AllowedGrpcService allowedService = AllowedGrpcService.builder() + .configuredChannelCredentials(creds) + .build(); + + Map servicesMap = new HashMap<>(); + servicesMap.put("xds:///target1", allowedService); + AllowedGrpcServices allowedGrpcServices = AllowedGrpcServices.create(servicesMap); + + BootstrapInfo bootstrapInfo = BootstrapInfo.builder() + .servers(ImmutableList.of()) + .node(DUMMY_NODE) + .allowedGrpcServices(Optional.of(allowedGrpcServices)) + .build(); + + BootstrapInfoGrpcServiceContextProvider provider = + new BootstrapInfoGrpcServiceContextProvider(bootstrapInfo, createServerInfo(false)); + + GrpcServiceXdsContext context = provider.getContextForTarget("xds:///target1"); + assertThat(context.validAllowedGrpcService().isPresent()).isTrue(); + assertThat(context.validAllowedGrpcService().get()).isEqualTo(allowedService); + + // Target not in map + GrpcServiceXdsContext context2 = provider.getContextForTarget("xds:///target2"); + assertThat(context2.validAllowedGrpcService().isPresent()).isFalse(); + } + + @Test + public void getContextForTarget_schemeSupported() { + BootstrapInfoGrpcServiceContextProvider provider = + new BootstrapInfoGrpcServiceContextProvider(DUMMY_BOOTSTRAP, createServerInfo(false)); + + assertThat(provider.getContextForTarget("dns:///foo").isTargetUriSchemeSupported()).isTrue(); + assertThat(provider.getContextForTarget("unknown:///foo").isTargetUriSchemeSupported()) + .isFalse(); + } + + @Test + public void getContextForTarget_invalidUri() { + BootstrapInfoGrpcServiceContextProvider provider = + new BootstrapInfoGrpcServiceContextProvider(DUMMY_BOOTSTRAP, createServerInfo(false)); + + GrpcServiceXdsContext context = provider.getContextForTarget("invalid:uri:with:colons"); + assertThat(context.isTargetUriSchemeSupported()).isFalse(); + } + + @Test + public void getContextForTarget_invalidAllowedGrpcServicesTypeFallbackToEmpty() { + BootstrapInfo bootstrapInfo = BootstrapInfo.builder().servers(ImmutableList.of()) + .node(DUMMY_NODE).allowedGrpcServices(Optional.of("invalid_type_string")).build(); + + BootstrapInfoGrpcServiceContextProvider provider = + new BootstrapInfoGrpcServiceContextProvider(bootstrapInfo, createServerInfo(false)); + + GrpcServiceXdsContext context = provider.getContextForTarget("xds:///any"); + assertThat(context.validAllowedGrpcService().isPresent()).isFalse(); + } +} diff --git a/xds/src/test/java/io/grpc/xds/FaultFilterTest.java b/xds/src/test/java/io/grpc/xds/FaultFilterTest.java index 8f0a33951b0..f74e39e727f 100644 --- a/xds/src/test/java/io/grpc/xds/FaultFilterTest.java +++ b/xds/src/test/java/io/grpc/xds/FaultFilterTest.java @@ -17,6 +17,7 @@ package io.grpc.xds; import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.mock; import com.google.protobuf.Any; import io.envoyproxy.envoy.extensions.filters.http.fault.v3.FaultAbort; @@ -26,6 +27,7 @@ import io.envoyproxy.envoy.type.v3.FractionalPercent.DenominatorType; import io.grpc.Status.Code; import io.grpc.internal.GrpcUtil; +import io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextProvider; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @@ -45,11 +47,14 @@ public void filterType_clientOnly() { public void parseFaultAbort_convertHttpStatus() { Any rawConfig = Any.pack( HTTPFault.newBuilder().setAbort(FaultAbort.newBuilder().setHttpStatus(404)).build()); - FaultConfig faultConfig = FILTER_PROVIDER.parseFilterConfig(rawConfig).config; + FaultConfig faultConfig = FILTER_PROVIDER.parseFilterConfig( + rawConfig, getFilterContext()).config; assertThat(faultConfig.faultAbort().status().getCode()) .isEqualTo(GrpcUtil.httpStatusToGrpcStatus(404).getCode()); - FaultConfig faultConfigOverride = FILTER_PROVIDER.parseFilterConfigOverride(rawConfig).config; + FaultConfig faultConfigOverride = + FILTER_PROVIDER.parseFilterConfigOverride( + rawConfig, getFilterContext()).config; assertThat(faultConfigOverride.faultAbort().status().getCode()) .isEqualTo(GrpcUtil.httpStatusToGrpcStatus(404).getCode()); } @@ -95,4 +100,9 @@ public void parseFaultAbort_withGrpcStatus() { .isEqualTo(FaultConfig.FractionalPercent.DenominatorType.MILLION); assertThat(faultAbort.status().getCode()).isEqualTo(Code.DEADLINE_EXCEEDED); } + + private static Filter.FilterContext getFilterContext() { + return Filter.FilterContext.builder() + .grpcServiceContextProvider(mock(GrpcServiceXdsContextProvider.class)).build(); + } } diff --git a/xds/src/test/java/io/grpc/xds/GcpAuthenticationFilterTest.java b/xds/src/test/java/io/grpc/xds/GcpAuthenticationFilterTest.java index f252c6f4ec1..2a1ee36d0e9 100644 --- a/xds/src/test/java/io/grpc/xds/GcpAuthenticationFilterTest.java +++ b/xds/src/test/java/io/grpc/xds/GcpAuthenticationFilterTest.java @@ -68,6 +68,7 @@ import io.grpc.xds.client.Locality; import io.grpc.xds.client.XdsResourceType; import io.grpc.xds.client.XdsResourceType.ResourceInvalidException; +import io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextProvider; import java.io.IOException; import java.util.Collections; import java.util.HashMap; @@ -112,8 +113,8 @@ public void testParseFilterConfig_withValidConfig() { .build(); Any anyMessage = Any.pack(config); - ConfigOrError result = FILTER_PROVIDER.parseFilterConfig(anyMessage); - + ConfigOrError result = + FILTER_PROVIDER.parseFilterConfig(anyMessage, getFilterContext()); assertNotNull(result.config); assertNull(result.errorDetail); assertEquals(20L, result.config.getCacheSize()); @@ -126,8 +127,8 @@ public void testParseFilterConfig_withZeroCacheSize() { .build(); Any anyMessage = Any.pack(config); - ConfigOrError result = FILTER_PROVIDER.parseFilterConfig(anyMessage); - + ConfigOrError result = + FILTER_PROVIDER.parseFilterConfig(anyMessage, getFilterContext()); assertNull(result.config); assertNotNull(result.errorDetail); assertTrue(result.errorDetail.contains("cache_config.cache_size must be greater than zero")); @@ -137,7 +138,7 @@ public void testParseFilterConfig_withZeroCacheSize() { public void testParseFilterConfig_withInvalidMessageType() { Message invalidMessage = Empty.getDefaultInstance(); ConfigOrError result = - FILTER_PROVIDER.parseFilterConfig(invalidMessage); + FILTER_PROVIDER.parseFilterConfig(invalidMessage, getFilterContext()); assertNull(result.config); assertThat(result.errorDetail).contains("Invalid config type"); @@ -468,8 +469,9 @@ private static LdsUpdate getLdsUpdate() { private static RdsUpdate getRdsUpdate() { RouteConfiguration routeConfiguration = buildRouteConfiguration("my-server", RDS_NAME, CLUSTER_NAME); - XdsResourceType.Args args = new XdsResourceType.Args( - XdsTestUtils.EMPTY_BOOTSTRAPPER_SERVER_INFO, "0", "0", null, null, null); + XdsResourceType.Args args = + new XdsResourceType.Args(XdsTestUtils.EMPTY_BOOTSTRAPPER_SERVER_INFO, "0", "0", + XdsTestUtils.EMPTY_BOOTSTRAP, null, null); try { return XdsRouteConfigureResource.getInstance().doParse(args, routeConfiguration); } catch (ResourceInvalidException ex) { @@ -521,4 +523,10 @@ private static CdsUpdate getCdsUpdateWithIncorrectAudienceWrapper() throws IOExc .lbPolicyConfig(getWrrLbConfigAsMap()); return cdsUpdate.parsedMetadata(parsedMetadata.build()).build(); } + + private static Filter.FilterContext getFilterContext() { + return Filter.FilterContext.builder() + .grpcServiceContextProvider(Mockito.mock(GrpcServiceXdsContextProvider.class)) + .build(); + } } diff --git a/xds/src/test/java/io/grpc/xds/GrpcBootstrapperImplTest.java b/xds/src/test/java/io/grpc/xds/GrpcBootstrapperImplTest.java index 0a303b7255d..aaf424277a4 100644 --- a/xds/src/test/java/io/grpc/xds/GrpcBootstrapperImplTest.java +++ b/xds/src/test/java/io/grpc/xds/GrpcBootstrapperImplTest.java @@ -37,6 +37,8 @@ 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.AllowedGrpcService; +import io.grpc.xds.internal.grpcservice.AllowedGrpcServices; import java.io.IOException; import java.util.List; import java.util.Map; @@ -97,6 +99,57 @@ 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(); + AllowedGrpcServices allowed = (AllowedGrpcServices) info.allowedGrpcServices().get(); + assertThat(allowed).isNotNull(); + assertThat(allowed.services()).containsKey("dns:///foo.com:443"); + AllowedGrpcService service = allowed.services().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/GrpcXdsClientImplDataTest.java b/xds/src/test/java/io/grpc/xds/GrpcXdsClientImplDataTest.java index be29e5e719f..7d88f9ebf94 100644 --- a/xds/src/test/java/io/grpc/xds/GrpcXdsClientImplDataTest.java +++ b/xds/src/test/java/io/grpc/xds/GrpcXdsClientImplDataTest.java @@ -1049,7 +1049,9 @@ public void parseClusterWeight() { .setWeight(UInt32Value.newBuilder().setValue(30)) .build(); ClusterWeight clusterWeight = - XdsRouteConfigureResource.parseClusterWeight(proto, filterRegistry).getStruct(); + XdsRouteConfigureResource + .parseClusterWeight(proto, filterRegistry, getXdsResourceTypeArgs(true)) + .getStruct(); assertThat(clusterWeight.name()).isEqualTo("cluster-foo"); assertThat(clusterWeight.weight()).isEqualTo(30); } @@ -1255,7 +1257,8 @@ public void parseHttpFilter_unsupportedButOptional() { .setIsOptional(true) .setTypedConfig(Any.pack(StringValue.of("unsupported"))) .build(); - assertThat(XdsListenerResource.parseHttpFilter(httpFilter, filterRegistry, true)).isNull(); + assertThat(XdsListenerResource.parseHttpFilter(httpFilter, filterRegistry, true, + getXdsResourceTypeArgs(true))).isNull(); } private static class SimpleFilterConfig implements FilterConfig { @@ -1294,12 +1297,14 @@ public TestFilter newInstance(String name) { } @Override - public ConfigOrError parseFilterConfig(Message rawProtoMessage) { + public ConfigOrError parseFilterConfig(Message rawProtoMessage, + FilterContext context) { return ConfigOrError.fromConfig(new SimpleFilterConfig(rawProtoMessage)); } @Override - public ConfigOrError parseFilterConfigOverride(Message rawProtoMessage) { + public ConfigOrError parseFilterConfigOverride(Message rawProtoMessage, + FilterContext context) { return ConfigOrError.fromConfig(new SimpleFilterConfig(rawProtoMessage)); } } @@ -1319,7 +1324,7 @@ public void parseHttpFilter_typedStructMigration() { .setValue(rawStruct) .build())).build(); FilterConfig config = XdsListenerResource.parseHttpFilter(httpFilter, filterRegistry, - true).getStruct(); + true, getXdsResourceTypeArgs(true)).getStruct(); assertThat(((SimpleFilterConfig)config).getConfig()).isEqualTo(rawStruct); HttpFilter httpFilterNewTypeStruct = HttpFilter.newBuilder() @@ -1330,7 +1335,7 @@ public void parseHttpFilter_typedStructMigration() { .setValue(rawStruct) .build())).build(); config = XdsListenerResource.parseHttpFilter(httpFilterNewTypeStruct, filterRegistry, - true).getStruct(); + true, getXdsResourceTypeArgs(true)).getStruct(); assertThat(((SimpleFilterConfig)config).getConfig()).isEqualTo(rawStruct); } @@ -1356,7 +1361,7 @@ public void parseOverrideHttpFilter_typedStructMigration() { .build()) ); Map map = XdsRouteConfigureResource.parseOverrideFilterConfigs( - rawFilterMap, filterRegistry).getStruct(); + rawFilterMap, filterRegistry, getXdsResourceTypeArgs(true)).getStruct(); assertThat(((SimpleFilterConfig)map.get("struct-0")).getConfig()).isEqualTo(rawStruct0); assertThat(((SimpleFilterConfig)map.get("struct-1")).getConfig()).isEqualTo(rawStruct1); } @@ -1368,7 +1373,8 @@ public void parseHttpFilter_unsupportedAndRequired() { .setName("unsupported.filter") .setTypedConfig(Any.pack(StringValue.of("string value"))) .build(); - assertThat(XdsListenerResource.parseHttpFilter(httpFilter, filterRegistry, true) + assertThat(XdsListenerResource + .parseHttpFilter(httpFilter, filterRegistry, true, getXdsResourceTypeArgs(true)) .getErrorDetail()).isEqualTo( "HttpFilter [unsupported.filter]" + "(type.googleapis.com/google.protobuf.StringValue) is required but unsupported " @@ -1385,7 +1391,8 @@ public void parseHttpFilter_routerFilterForClient() { .setTypedConfig(Any.pack(Router.getDefaultInstance())) .build(); FilterConfig config = XdsListenerResource.parseHttpFilter( - httpFilter, filterRegistry, true /* isForClient */).getStruct(); + httpFilter, filterRegistry, true /* isForClient */, getXdsResourceTypeArgs(true)) + .getStruct(); assertThat(config.typeUrl()).isEqualTo(RouterFilter.TYPE_URL); } @@ -1399,7 +1406,8 @@ public void parseHttpFilter_routerFilterForServer() { .setTypedConfig(Any.pack(Router.getDefaultInstance())) .build(); FilterConfig config = XdsListenerResource.parseHttpFilter( - httpFilter, filterRegistry, false /* isForClient */).getStruct(); + httpFilter, filterRegistry, false /* isForClient */, getXdsResourceTypeArgs(false)) + .getStruct(); assertThat(config.typeUrl()).isEqualTo(RouterFilter.TYPE_URL); } @@ -1426,7 +1434,8 @@ public void parseHttpFilter_faultConfigForClient() { .build())) .build(); FilterConfig config = XdsListenerResource.parseHttpFilter( - httpFilter, filterRegistry, true /* isForClient */).getStruct(); + httpFilter, filterRegistry, true /* isForClient */, getXdsResourceTypeArgs(true)) + .getStruct(); assertThat(config).isInstanceOf(FaultConfig.class); } @@ -1453,7 +1462,8 @@ public void parseHttpFilter_faultConfigUnsupportedForServer() { .build())) .build(); StructOrError config = - XdsListenerResource.parseHttpFilter(httpFilter, filterRegistry, false /* isForClient */); + XdsListenerResource.parseHttpFilter(httpFilter, filterRegistry, false /* isForClient */, + getXdsResourceTypeArgs(false)); assertThat(config.getErrorDetail()).isEqualTo( "HttpFilter [envoy.fault](" + FaultFilter.TYPE_URL + ") is required but " + "unsupported for server"); @@ -1482,7 +1492,8 @@ public void parseHttpFilter_rbacConfigForServer() { .build())) .build(); FilterConfig config = XdsListenerResource.parseHttpFilter( - httpFilter, filterRegistry, false /* isForClient */).getStruct(); + httpFilter, filterRegistry, false /* isForClient */, getXdsResourceTypeArgs(false)) + .getStruct(); assertThat(config).isInstanceOf(RbacConfig.class); } @@ -1509,7 +1520,8 @@ public void parseHttpFilter_rbacConfigUnsupportedForClient() { .build())) .build(); StructOrError config = - XdsListenerResource.parseHttpFilter(httpFilter, filterRegistry, true /* isForClient */); + XdsListenerResource.parseHttpFilter(httpFilter, filterRegistry, true /* isForClient */, + getXdsResourceTypeArgs(true)); assertThat(config.getErrorDetail()).isEqualTo( "HttpFilter [envoy.auth](" + RbacFilter.TYPE_URL + ") is required but " + "unsupported for client"); @@ -1534,7 +1546,8 @@ public void parseOverrideRbacFilterConfig() { .build(); Map configOverrides = ImmutableMap.of("envoy.auth", Any.pack(rbacPerRoute)); Map parsedConfigs = - XdsRouteConfigureResource.parseOverrideFilterConfigs(configOverrides, filterRegistry) + XdsRouteConfigureResource.parseOverrideFilterConfigs(configOverrides, filterRegistry, + getXdsResourceTypeArgs(true)) .getStruct(); assertThat(parsedConfigs).hasSize(1); assertThat(parsedConfigs).containsKey("envoy.auth"); @@ -1555,7 +1568,8 @@ public void parseOverrideFilterConfigs_unsupportedButOptional() { .setIsOptional(true).setConfig(Any.pack(StringValue.of("string value"))) .build())); Map parsedConfigs = - XdsRouteConfigureResource.parseOverrideFilterConfigs(configOverrides, filterRegistry) + XdsRouteConfigureResource.parseOverrideFilterConfigs(configOverrides, filterRegistry, + getXdsResourceTypeArgs(true)) .getStruct(); assertThat(parsedConfigs).hasSize(1); assertThat(parsedConfigs).containsKey("envoy.fault"); @@ -1574,7 +1588,9 @@ public void parseOverrideFilterConfigs_unsupportedAndRequired() { Any.pack(io.envoyproxy.envoy.config.route.v3.FilterConfig.newBuilder() .setIsOptional(false).setConfig(Any.pack(StringValue.of("string value"))) .build())); - assertThat(XdsRouteConfigureResource.parseOverrideFilterConfigs(configOverrides, filterRegistry) + assertThat(XdsRouteConfigureResource + .parseOverrideFilterConfigs(configOverrides, filterRegistry, + getXdsResourceTypeArgs(true)) .getErrorDetail()).isEqualTo( "HttpFilter [unsupported.filter]" + "(type.googleapis.com/google.protobuf.StringValue) is required but unsupported"); @@ -1584,7 +1600,9 @@ public void parseOverrideFilterConfigs_unsupportedAndRequired() { Any.pack(httpFault), "unsupported.filter", Any.pack(StringValue.of("string value"))); - assertThat(XdsRouteConfigureResource.parseOverrideFilterConfigs(configOverrides, filterRegistry) + assertThat(XdsRouteConfigureResource + .parseOverrideFilterConfigs(configOverrides, filterRegistry, + getXdsResourceTypeArgs(true)) .getErrorDetail()).isEqualTo( "HttpFilter [unsupported.filter]" + "(type.googleapis.com/google.protobuf.StringValue) is required but unsupported"); @@ -3614,7 +3632,7 @@ private static Filter buildHttpConnectionManagerFilter(HttpFilter... httpFilters private XdsResourceType.Args getXdsResourceTypeArgs(boolean isTrustedServer) { return new XdsResourceType.Args( - ServerInfo.create("http://td", "", false, isTrustedServer, false, false), "1.0", null, null, null, null + ServerInfo.create("http://td", "", false, isTrustedServer, false, false), "1.0", null, XdsTestUtils.EMPTY_BOOTSTRAP, null, null ); } } diff --git a/xds/src/test/java/io/grpc/xds/RbacFilterTest.java b/xds/src/test/java/io/grpc/xds/RbacFilterTest.java index 334e159dd1d..ca59ab4e524 100644 --- a/xds/src/test/java/io/grpc/xds/RbacFilterTest.java +++ b/xds/src/test/java/io/grpc/xds/RbacFilterTest.java @@ -299,7 +299,7 @@ public void handleException() { .putPolicies("policy-name", Policy.newBuilder().setCondition(Expr.newBuilder().build()).build()) .build()).build(); - result = FILTER_PROVIDER.parseFilterConfig(Any.pack(rawProto)); + result = FILTER_PROVIDER.parseFilterConfig(Any.pack(rawProto), getFilterContext()); assertThat(result.errorDetail).isNotNull(); } @@ -321,7 +321,8 @@ public void overrideConfig() { RbacConfig original = RbacConfig.create(authconfig); RBACPerRoute rbacPerRoute = RBACPerRoute.newBuilder().build(); - RbacConfig override = FILTER_PROVIDER.parseFilterConfigOverride(Any.pack(rbacPerRoute)).config; + RbacConfig override = FILTER_PROVIDER.parseFilterConfigOverride(Any.pack(rbacPerRoute), + getFilterContext()).config; assertThat(override).isEqualTo(RbacConfig.create(null)); ServerInterceptor interceptor = FILTER_PROVIDER.newInstance(name).buildServerInterceptor(original, override); @@ -346,22 +347,26 @@ public void ignoredConfig() { Message rawProto = io.envoyproxy.envoy.extensions.filters.http.rbac.v3.RBAC.newBuilder() .setRules(RBAC.newBuilder().setAction(Action.LOG) .putPolicies("policy-name", Policy.newBuilder().build()).build()).build(); - ConfigOrError result = FILTER_PROVIDER.parseFilterConfig(Any.pack(rawProto)); + ConfigOrError result = + FILTER_PROVIDER.parseFilterConfig(Any.pack(rawProto), getFilterContext()); assertThat(result.config).isEqualTo(RbacConfig.create(null)); } @Test public void testOrderIndependenceOfPolicies() { Message rawProto = buildComplexRbac(ImmutableList.of(1, 2, 3, 4, 5, 6), true); - ConfigOrError ascFirst = FILTER_PROVIDER.parseFilterConfig(Any.pack(rawProto)); + ConfigOrError ascFirst = + FILTER_PROVIDER.parseFilterConfig(Any.pack(rawProto), getFilterContext()); rawProto = buildComplexRbac(ImmutableList.of(1, 2, 3, 4, 5, 6), false); - ConfigOrError ascLast = FILTER_PROVIDER.parseFilterConfig(Any.pack(rawProto)); + ConfigOrError ascLast = + FILTER_PROVIDER.parseFilterConfig(Any.pack(rawProto), getFilterContext()); assertThat(ascFirst.config).isEqualTo(ascLast.config); rawProto = buildComplexRbac(ImmutableList.of(6, 5, 4, 3, 2, 1), true); - ConfigOrError decFirst = FILTER_PROVIDER.parseFilterConfig(Any.pack(rawProto)); + ConfigOrError decFirst = + FILTER_PROVIDER.parseFilterConfig(Any.pack(rawProto), getFilterContext()); assertThat(ascFirst.config).isEqualTo(decFirst.config); } @@ -390,7 +395,7 @@ private ConfigOrError parseRaw(List permissionList, List principalList) { Message rawProto = buildRbac(permissionList, principalList); Any proto = Any.pack(rawProto); - return FILTER_PROVIDER.parseFilterConfig(proto); + return FILTER_PROVIDER.parseFilterConfig(proto, getFilterContext()); } private io.envoyproxy.envoy.extensions.filters.http.rbac.v3.RBAC buildRbac( @@ -458,6 +463,12 @@ private ConfigOrError parseOverride(List permissionList, RBACPerRoute rbacPerRoute = RBACPerRoute.newBuilder().setRbac( buildRbac(permissionList, principalList)).build(); Any proto = Any.pack(rbacPerRoute); - return FILTER_PROVIDER.parseFilterConfigOverride(proto); + return FILTER_PROVIDER.parseFilterConfigOverride(proto, getFilterContext()); + } + + private Filter.FilterContext getFilterContext() { + return Filter.FilterContext.builder().grpcServiceContextProvider(mock( + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextProvider.class)) + .build(); } } diff --git a/xds/src/test/java/io/grpc/xds/StatefulFilter.java b/xds/src/test/java/io/grpc/xds/StatefulFilter.java index 4ef662c7ccd..7626222dc04 100644 --- a/xds/src/test/java/io/grpc/xds/StatefulFilter.java +++ b/xds/src/test/java/io/grpc/xds/StatefulFilter.java @@ -128,12 +128,13 @@ public synchronized int getCount() { } @Override - public ConfigOrError parseFilterConfig(Message rawProtoMessage) { + public ConfigOrError parseFilterConfig(Message rawProtoMessage, FilterContext context) { return ConfigOrError.fromConfig(Config.fromProto(rawProtoMessage, typeUrl)); } @Override - public ConfigOrError parseFilterConfigOverride(Message rawProtoMessage) { + public ConfigOrError parseFilterConfigOverride( + Message rawProtoMessage, FilterContext context) { return ConfigOrError.fromConfig(Config.fromProto(rawProtoMessage, typeUrl)); } } diff --git a/xds/src/test/java/io/grpc/xds/XdsTestUtils.java b/xds/src/test/java/io/grpc/xds/XdsTestUtils.java index f81957ee311..93113411b5e 100644 --- a/xds/src/test/java/io/grpc/xds/XdsTestUtils.java +++ b/xds/src/test/java/io/grpc/xds/XdsTestUtils.java @@ -88,6 +88,11 @@ public class XdsTestUtils { static final Bootstrapper.ServerInfo EMPTY_BOOTSTRAPPER_SERVER_INFO = Bootstrapper.ServerInfo.create( "td.googleapis.com", InsecureChannelCredentials.create(), false, true, false, false); + static final Bootstrapper.BootstrapInfo EMPTY_BOOTSTRAP = + Bootstrapper.BootstrapInfo.builder() + .servers(com.google.common.collect.ImmutableList.of(EMPTY_BOOTSTRAPPER_SERVER_INFO)) + .node(io.grpc.xds.client.EnvoyProtoData.Node.newBuilder().setId("node-id").build()) + .build(); public static final String ENDPOINT_HOSTNAME = "data-host"; public static final int ENDPOINT_PORT = 1234; @@ -252,7 +257,7 @@ static XdsConfig getDefaultXdsConfig(String serverHostName) RouteConfiguration routeConfiguration = buildRouteConfiguration(serverHostName, RDS_NAME, CLUSTER_NAME); XdsResourceType.Args args = new XdsResourceType.Args( - EMPTY_BOOTSTRAPPER_SERVER_INFO, "0", "0", null, null, null); + EMPTY_BOOTSTRAPPER_SERVER_INFO, "0", "0", EMPTY_BOOTSTRAP, null, null); XdsRouteConfigureResource.RdsUpdate rdsUpdate = XdsRouteConfigureResource.getInstance().doParse(args, routeConfiguration); 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/extauthz/ExtAuthzConfigParserTest.java b/xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzConfigParserTest.java new file mode 100644 index 00000000000..373ad98552d --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzConfigParserTest.java @@ -0,0 +1,269 @@ +/* + * 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 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().setToken("fake-token").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 parse_missingGrpcService_throws() { + ExtAuthz extAuthz = ExtAuthz.newBuilder().build(); + try { + ExtAuthzConfigParser.parse(extAuthz, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil.dummyProvider()); + fail("Expected ExtAuthzParseException"); + } catch (ExtAuthzParseException e) { + assertThat(e).hasMessageThat() + .isEqualTo("unsupported ExtAuthz service type: only grpc_service is supported"); + } + } + + @Test + public void parse_invalidGrpcService_throws() { + ExtAuthz extAuthz = ExtAuthz.newBuilder() + .setGrpcService(io.envoyproxy.envoy.config.core.v3.GrpcService.newBuilder().build()) + .build(); + try { + 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:"); + } + } + + @Test + public void parse_invalidAllowExpression_throws() { + ExtAuthz extAuthz = extAuthzBuilder + .setDecoderHeaderMutationRules(HeaderMutationRules.newBuilder() + .setAllowExpression(RegexMatcher.newBuilder().setRegex("[invalid").build()).build()) + .build(); + try { + 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:"); + } + } + + @Test + public void parse_invalidDisallowExpression_throws() { + ExtAuthz extAuthz = extAuthzBuilder + .setDecoderHeaderMutationRules(HeaderMutationRules.newBuilder() + .setDisallowExpression(RegexMatcher.newBuilder().setRegex("[invalid").build()).build()) + .build(); + try { + 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:"); + } + } + + @Test + 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 = 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()).isNotEmpty(); + 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 parse_saneDefaults() throws ExtAuthzParseException { + ExtAuthz extAuthz = extAuthzBuilder.build(); + + ExtAuthzConfig config = ExtAuthzConfigParser.parse(extAuthz, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil.dummyProvider()); + + 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 parse_headerMutationRules_allowExpressionOnly() throws ExtAuthzParseException { + ExtAuthz extAuthz = extAuthzBuilder + .setDecoderHeaderMutationRules(HeaderMutationRules.newBuilder() + .setAllowExpression(RegexMatcher.newBuilder().setRegex("allow.*").build()).build()) + .build(); + + ExtAuthzConfig config = ExtAuthzConfigParser.parse(extAuthz, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil.dummyProvider()); + + 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 parse_headerMutationRules_disallowExpressionOnly() throws ExtAuthzParseException { + ExtAuthz extAuthz = + extAuthzBuilder.setDecoderHeaderMutationRules(HeaderMutationRules.newBuilder() + .setDisallowExpression(RegexMatcher.newBuilder().setRegex("disallow.*").build()) + .build()).build(); + + ExtAuthzConfig config = ExtAuthzConfigParser.parse(extAuthz, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil.dummyProvider()); + + 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 parse_filterEnabled_hundred() throws ExtAuthzParseException { + ExtAuthz extAuthz = extAuthzBuilder + .setFilterEnabled(RuntimeFractionalPercent.newBuilder().setDefaultValue(FractionalPercent + .newBuilder().setNumerator(25).setDenominator(DenominatorType.HUNDRED).build()).build()) + .build(); + + ExtAuthzConfig config = ExtAuthzConfigParser.parse(extAuthz, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil.dummyProvider()); + + assertThat(config.filterEnabled()).isEqualTo(Matchers.FractionMatcher.create(25, 100)); + } + + @Test + 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 = ExtAuthzConfigParser.parse(extAuthz, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil.dummyProvider()); + + assertThat(config.filterEnabled()) + .isEqualTo(Matchers.FractionMatcher.create(123456, 1_000_000)); + } + + @Test + public void parse_filterEnabled_unrecognizedDenominator() { + ExtAuthz extAuthz = extAuthzBuilder.setFilterEnabled(RuntimeFractionalPercent.newBuilder() + .setDefaultValue( + FractionalPercent.newBuilder().setNumerator(1).setDenominatorValue(4).build()) + .build()).build(); + + try { + ExtAuthzConfigParser.parse(extAuthz, + io.grpc.xds.internal.grpcservice.GrpcServiceXdsContextTestUtil.dummyProvider()); + fail("Expected ExtAuthzParseException"); + } catch (ExtAuthzParseException e) { + assertThat(e).hasMessageThat().isEqualTo("Unknown denominator type: UNRECOGNIZED"); + } + } +} 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..39310a2dc63 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigParserTest.java @@ -0,0 +1,526 @@ +/* + * 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 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"); + } + + static class RecordingMetadataApplier extends io.grpc.CallCredentials.MetadataApplier { + boolean applied = false; + boolean failed = false; + io.grpc.Metadata appliedHeaders = null; + + @Override + public void apply(io.grpc.Metadata headers) { + applied = true; + appliedHeaders = headers; + } + + @Override + public void fail(io.grpc.Status status) { + failed = true; + } + } + + static class FakeRequestInfo extends io.grpc.CallCredentials.RequestInfo { + private final io.grpc.SecurityLevel securityLevel; + private final io.grpc.MethodDescriptor methodDescriptor; + + FakeRequestInfo(io.grpc.SecurityLevel securityLevel) { + this.securityLevel = securityLevel; + this.methodDescriptor = io.grpc.MethodDescriptor.newBuilder() + .setType(io.grpc.MethodDescriptor.MethodType.UNARY) + .setFullMethodName("test_service/test_method") + .setRequestMarshaller(new NoopMarshaller()) + .setResponseMarshaller(new NoopMarshaller()) + .build(); + } + + private static class NoopMarshaller implements io.grpc.MethodDescriptor.Marshaller { + @Override + public java.io.InputStream stream(T value) { + return null; + } + + @Override + public T parse(java.io.InputStream stream) { + return null; + } + } + + @Override + public io.grpc.MethodDescriptor getMethodDescriptor() { + return methodDescriptor; + } + + @Override + public io.grpc.SecurityLevel getSecurityLevel() { + return securityLevel; + } + + @Override + public String getAuthority() { + return "dummy-authority"; + } + + @Override + public io.grpc.Attributes getTransportAttrs() { + return io.grpc.Attributes.EMPTY; + } + } + + + @Test + public void securityAwareCredentials_secureConnection_appliesToken() throws Exception { + 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()); + + io.grpc.CallCredentials creds = config.googleGrpc().callCredentials().get(); + RecordingMetadataApplier applier = new RecordingMetadataApplier(); + java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1); + + creds.applyRequestMetadata( + new FakeRequestInfo(io.grpc.SecurityLevel.PRIVACY_AND_INTEGRITY), + Runnable::run, // Use direct executor to avoid async issues in test + new io.grpc.CallCredentials.MetadataApplier() { + @Override + public void apply(io.grpc.Metadata headers) { + applier.apply(headers); + latch.countDown(); + } + + @Override + public void fail(io.grpc.Status status) { + applier.fail(status); + latch.countDown(); + } + }); + + latch.await(5, java.util.concurrent.TimeUnit.SECONDS); + assertThat(applier.applied).isTrue(); + assertThat(applier.appliedHeaders.get( + io.grpc.Metadata.Key.of("Authorization", io.grpc.Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("Bearer test_token"); + } + + @Test + public void securityAwareCredentials_insecureConnection_appliesEmptyMetadata() throws Exception { + 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()); + + io.grpc.CallCredentials creds = config.googleGrpc().callCredentials().get(); + RecordingMetadataApplier applier = new RecordingMetadataApplier(); + + creds.applyRequestMetadata( + new FakeRequestInfo(io.grpc.SecurityLevel.NONE), + Runnable::run, + applier); + + assertThat(applier.applied).isTrue(); + assertThat(applier.appliedHeaders.get( + io.grpc.Metadata.Key.of("Authorization", io.grpc.Metadata.ASCII_STRING_MARSHALLER))) + .isNull(); + } +} 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/HeaderValueValidationUtilsTest.java b/xds/src/test/java/io/grpc/xds/internal/grpcservice/HeaderValueValidationUtilsTest.java new file mode 100644 index 00000000000..c4658f3f305 --- /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 isDisallowed_string_emptyKey() { + assertThat(HeaderValueValidationUtils.isDisallowed("")).isTrue(); + } + + @Test + public void isDisallowed_string_tooLongKey() { + String longKey = new String(new char[16385]).replace('\0', 'a'); + assertThat(HeaderValueValidationUtils.isDisallowed(longKey)).isTrue(); + } + + @Test + public void isDisallowed_string_notLowercase() { + assertThat(HeaderValueValidationUtils.isDisallowed("Content-Type")).isTrue(); + } + + @Test + public void isDisallowed_string_grpcPrefix() { + assertThat(HeaderValueValidationUtils.isDisallowed("grpc-timeout")).isTrue(); + } + + @Test + public void isDisallowed_string_systemHeader_colon() { + assertThat(HeaderValueValidationUtils.isDisallowed(":authority")).isTrue(); + } + + @Test + public void isDisallowed_string_systemHeader_host() { + assertThat(HeaderValueValidationUtils.isDisallowed("host")).isTrue(); + } + + @Test + public void isDisallowed_string_valid() { + assertThat(HeaderValueValidationUtils.isDisallowed("content-type")).isFalse(); + } + + @Test + public void isDisallowed_headerValue_tooLongValue() { + String longValue = new String(new char[16385]).replace('\0', 'v'); + HeaderValue header = HeaderValue.create("content-type", longValue); + assertThat(HeaderValueValidationUtils.isDisallowed(header)).isTrue(); + } + + @Test + public void isDisallowed_headerValue_tooLongRawValue() { + ByteString longRawValue = ByteString.copyFrom(new byte[16385]); + HeaderValue header = HeaderValue.create("content-type", longRawValue); + assertThat(HeaderValueValidationUtils.isDisallowed(header)).isTrue(); + } + + @Test + public void isDisallowed_headerValue_valid() { + HeaderValue header = HeaderValue.create("content-type", "application/grpc"); + assertThat(HeaderValueValidationUtils.isDisallowed(header)).isFalse(); + } +} 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..9f5cb75460f --- /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 com.google.re2j.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); + } +} 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..e880c197450 --- /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.headermutations.HeaderMutationRulesParseException; +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_throwsHeaderMutationRulesParseException() { + HeaderMutationRules proto = HeaderMutationRules.newBuilder() + .setAllowExpression(RegexMatcher.newBuilder().setRegex("allow-[")) + .build(); + + HeaderMutationRulesParseException exception = assertThrows( + HeaderMutationRulesParseException.class, () -> HeaderMutationRulesParser.parse(proto)); + + assertThat(exception).hasMessageThat().contains("Invalid regex pattern for allow_expression"); + } + + @Test + public void parse_invalidRegexDisallowExpression_throwsHeaderMutationRulesParseException() { + HeaderMutationRules proto = HeaderMutationRules.newBuilder() + .setDisallowExpression(RegexMatcher.newBuilder().setRegex("disallow-[")) + .build(); + + HeaderMutationRulesParseException exception = assertThrows( + HeaderMutationRulesParseException.class, () -> HeaderMutationRulesParser.parse(proto)); + + assertThat(exception).hasMessageThat() + .contains("Invalid regex pattern for disallow_expression"); + } +}