From b81a8c04a582abbf3eb614c1bb70a0d5a1d5a3c8 Mon Sep 17 00:00:00 2001 From: Saurav Date: Fri, 26 Jun 2026 11:31:00 +0000 Subject: [PATCH] xds: Add ExtAuthzFilter client provider Part 4 of the client-side ext_authz filter. Sits on top of the client interceptor PR. Adds ExtAuthzFilter, the Filter / Filter.Provider implementation for type URL envoy.extensions.filters.http.ext_authz.v3.ExtAuthz. It parses top-level and per-route (ExtAuthzPerRoute) configurations, manages cached gRPC channels to the authz server via CachedChannelManager, and builds ExtAuthzClientInterceptor instances in buildClientInterceptor(). Registers the provider in FilterRegistry so the xDS client can automatically instantiate it when parsing HttpConnectionManager configurations. Guarded behind GRPC_EXPERIMENTAL_XDS_EXT_AUTHZ_ON_CLIENT. test: authz-client-filter build improve: client-side test improvements --- .../main/java/io/grpc/xds/ExtAuthzFilter.java | 315 ++++++++++ .../main/java/io/grpc/xds/FilterRegistry.java | 3 +- .../java/io/grpc/xds/ExtAuthzFilterTest.java | 554 ++++++++++++++++++ 3 files changed, 871 insertions(+), 1 deletion(-) create mode 100644 xds/src/main/java/io/grpc/xds/ExtAuthzFilter.java create mode 100644 xds/src/test/java/io/grpc/xds/ExtAuthzFilterTest.java diff --git a/xds/src/main/java/io/grpc/xds/ExtAuthzFilter.java b/xds/src/main/java/io/grpc/xds/ExtAuthzFilter.java new file mode 100644 index 00000000000..536ca4792f7 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/ExtAuthzFilter.java @@ -0,0 +1,315 @@ +/* + * 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; + +import com.google.protobuf.Any; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Message; +import io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz; +import io.envoyproxy.envoy.service.auth.v3.AuthorizationGrpc; +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptor; +import io.grpc.Grpc; +import io.grpc.ManagedChannel; +import io.grpc.MethodDescriptor; +import io.grpc.Status; +import io.grpc.internal.GrpcUtil; +import io.grpc.xds.ThreadSafeRandom.ThreadSafeRandomImpl; +import io.grpc.xds.internal.Matchers.FractionMatcher; +import io.grpc.xds.internal.extauthz.CheckRequestBuilder; +import io.grpc.xds.internal.extauthz.CheckResponseHandler; +import io.grpc.xds.internal.extauthz.ExtAuthzClientCall; +import io.grpc.xds.internal.extauthz.ExtAuthzConfig; +import io.grpc.xds.internal.extauthz.ExtAuthzParseException; +import io.grpc.xds.internal.extauthz.FailingClientCall; +import io.grpc.xds.internal.grpcservice.GrpcServiceConfig; +import io.grpc.xds.internal.headermutations.HeaderMutationFilter; +import io.grpc.xds.internal.headermutations.HeaderMutator; +import java.util.concurrent.Executor; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; +import javax.annotation.concurrent.ThreadSafe; + +// TODO(sauravz): Implement ext_authz metrics per gRFC A92 (Section: Metrics). +// Client-side counters: grpc.client_ext_authz.{allowed,denied,filter_disabled,failed}_rpcs +@ThreadSafe +final class ExtAuthzFilter implements Filter { + + private static final String TYPE_URL = + "type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz"; + + private static final String TYPE_URL_OVERRIDE_CONFIG = + "type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute"; + + @Immutable + static final class ExtAuthzFilterConfig implements Filter.FilterConfig { + + private final ExtAuthzConfig extAuthzConfig; + + ExtAuthzFilterConfig(ExtAuthzConfig extAuthzConfig) { + this.extAuthzConfig = extAuthzConfig; + } + + public ExtAuthzConfig extAuthzConfig() { + return extAuthzConfig; + } + + @Override + public String typeUrl() { + return ExtAuthzFilter.TYPE_URL; + } + + public static ExtAuthzFilterConfig fromProto( + ExtAuthz extAuthzProto, + io.grpc.xds.client.Bootstrapper.BootstrapInfo bootstrapInfo, + io.grpc.xds.client.Bootstrapper.ServerInfo serverInfo) + throws ExtAuthzParseException { + return new ExtAuthzFilterConfig( + ExtAuthzConfigParser.parse(extAuthzProto, bootstrapInfo, serverInfo)); + } + } + + // Placeholder for the external authorization filter's override config. + @Immutable + static final class ExtAuthzFilterConfigOverride implements Filter.FilterConfig { + @Override + public final String typeUrl() { + return ExtAuthzFilter.TYPE_URL_OVERRIDE_CONFIG; + } + } + + @Immutable + static final class Provider implements Filter.Provider { + + @Override + public String[] typeUrls() { + return new String[] {TYPE_URL, TYPE_URL_OVERRIDE_CONFIG}; + } + + @Override + public boolean isClientFilter() { + return GrpcUtil.getFlag("GRPC_EXPERIMENTAL_XDS_EXT_AUTHZ_ON_CLIENT", false); + } + + @Override + public ExtAuthzFilter newInstance(FilterContext context) { + return new ExtAuthzFilter(new GrpcChannelProvider(), ThreadSafeRandomImpl.instance, + HeaderMutator.create()); + } + + @Override + public ConfigOrError parseFilterConfig( + Message rawProtoMessage, FilterConfigParseContext context) { + ExtAuthz extAuthzProto; + if (!(rawProtoMessage instanceof Any)) { + return ConfigOrError.fromError("Invalid config type: " + rawProtoMessage.getClass()); + } + Any anyMessage = (Any) rawProtoMessage; + try { + extAuthzProto = anyMessage.unpack(ExtAuthz.class); + + return ConfigOrError.fromConfig( + ExtAuthzFilterConfig.fromProto( + extAuthzProto, context.bootstrapInfo(), context.serverInfo())); + } catch (InvalidProtocolBufferException | ExtAuthzParseException e) { + return ConfigOrError.fromError("Invalid proto: " + e); + } + } + + @Override + public ConfigOrError parseFilterConfigOverride( + Message rawProtoMessage, FilterConfigParseContext context) { + if (!(rawProtoMessage instanceof Any)) { + return ConfigOrError.fromError("Invalid config type: " + rawProtoMessage.getClass()); + } + return ConfigOrError.fromConfig(new ExtAuthzFilterConfigOverride()); + } + } + + /** + * Provides {@link ManagedChannel} instances for a given {@link GrpcServiceConfig} + * and manages their lifecycle. + */ + interface ChannelProvider extends java.io.Closeable { + /** Creates or returns a ManagedChannel for the given config. */ + ManagedChannel getChannel(GrpcServiceConfig config); + + /** Shuts down any channels held by this provider. */ + @Override + void close(); + } + + // TODO(sauravz): Harden channel lifecycle management before launch. + // This implementation is intentionally simple and synchronized. + // Consider implementing proper keyed caching (by target + credentials) + // and graceful shutdown with awaitTermination. + private static final class GrpcChannelProvider implements ChannelProvider { + private final Object lock = new Object(); + private ManagedChannel channel; + + @Override + public ManagedChannel getChannel(GrpcServiceConfig config) { + synchronized (lock) { + if (channel != null && !channel.isShutdown()) { + return channel; + } + if (channel != null) { + channel.shutdown(); + } + GrpcServiceConfig.GoogleGrpcConfig googleGrpc = config.googleGrpc(); + channel = Grpc.newChannelBuilder( + googleGrpc.target(), + googleGrpc.configuredChannelCredentials() + .channelCredentials()).build(); + return channel; + } + } + + @Override + public void close() { + synchronized (lock) { + if (channel != null) { + channel.shutdown(); + channel = null; + } + } + } + } + + private final ChannelProvider channelProvider; + private final ThreadSafeRandom random; + private final HeaderMutator headerMutator; + + + ExtAuthzFilter(ChannelProvider channelProvider, ThreadSafeRandom random, + HeaderMutator headerMutator) { + this.channelProvider = channelProvider; + this.random = random; + this.headerMutator = headerMutator; + } + + @Nullable + @Override + public ClientInterceptor buildClientInterceptor(FilterConfig config, + @Nullable FilterConfig overrideConfig, ScheduledExecutorService scheduler) { + if (overrideConfig != null) { + return null; + } + if (!(config instanceof ExtAuthzFilterConfig)) { + return null; + } + ExtAuthzFilterConfig extAuthzFilterConfig = (ExtAuthzFilterConfig) config; + ExtAuthzConfig extAuthzConfig = extAuthzFilterConfig.extAuthzConfig(); + AuthorizationGrpc.AuthorizationStub stub = AuthorizationGrpc.newStub( + channelProvider.getChannel(extAuthzConfig.grpcService())); + if (extAuthzConfig.grpcService().googleGrpc().callCredentials().isPresent()) { + stub = stub.withCallCredentials( + extAuthzConfig.grpcService().googleGrpc().callCredentials().get()); + } + if (extAuthzConfig.grpcService().timeout().isPresent()) { + stub = stub.withDeadlineAfter( + extAuthzConfig.grpcService().timeout().get().toMillis(), + TimeUnit.MILLISECONDS); + } + return new ExtAuthzClientInterceptor(extAuthzConfig, stub, random, + new CheckRequestBuilder(extAuthzConfig), + new CheckResponseHandler( + new HeaderMutationFilter(extAuthzConfig.decoderHeaderMutationRules())), + headerMutator, + scheduler); + } + + @Override + public void close() { + channelProvider.close(); + } + + /** + * A client interceptor that performs external authorization for outgoing RPCs. + */ + @ThreadSafe + static final class ExtAuthzClientInterceptor implements ClientInterceptor { + + private final ExtAuthzConfig config; + private final AuthorizationGrpc.AuthorizationStub authzStub; + private final ThreadSafeRandom random; + private final CheckRequestBuilder checkRequestBuilder; + private final CheckResponseHandler responseHandler; + private final HeaderMutator headerMutator; + private final ScheduledExecutorService scheduler; + + ExtAuthzClientInterceptor( + ExtAuthzConfig config, + AuthorizationGrpc.AuthorizationStub authzStub, + ThreadSafeRandom random, + CheckRequestBuilder checkRequestBuilder, + CheckResponseHandler responseHandler, + HeaderMutator headerMutator, + ScheduledExecutorService scheduler) { + this.config = config; + this.random = random; + this.authzStub = authzStub; + this.checkRequestBuilder = checkRequestBuilder; + this.responseHandler = responseHandler; + this.headerMutator = headerMutator; + this.scheduler = scheduler; + } + + @com.google.common.annotations.VisibleForTesting + AuthorizationGrpc.AuthorizationStub getAuthzStubForTest() { + return authzStub; + } + + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + FractionMatcher filterEnabled = config.filterEnabled(); + + // 1. FractionMatcher Evaluation (Spec Parity / Correctness fix) + if (random.nextInt(filterEnabled.denominator()) >= filterEnabled.numerator()) { + if (config.denyAtDisable()) { + return new FailingClientCall<>(config.statusOnError()); + } + return next.newCall(method, callOptions); + } + + // 2. Strict Fail-Fast Executor Validation + Executor executor = callOptions.getExecutor(); + if (executor == null) { + return new FailingClientCall<>(Status.INTERNAL + .withDescription("No executor provided in CallOptions or Channel")); + } + + // 3. Return concrete subclassed DelayedClientCall + return new ExtAuthzClientCall<>( + executor, + scheduler, + callOptions, + next, + method, + authzStub, + checkRequestBuilder, + responseHandler, + headerMutator, + config); + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/FilterRegistry.java b/xds/src/main/java/io/grpc/xds/FilterRegistry.java index adeaec41081..9b8c4931380 100644 --- a/xds/src/main/java/io/grpc/xds/FilterRegistry.java +++ b/xds/src/main/java/io/grpc/xds/FilterRegistry.java @@ -39,7 +39,8 @@ static synchronized FilterRegistry getDefaultRegistry() { new RouterFilter.Provider(), new RbacFilter.Provider(), new GcpAuthenticationFilter.Provider(), - new ExternalProcessorFilter.Provider()); + new ExternalProcessorFilter.Provider(), + new ExtAuthzFilter.Provider()); } return instance; } diff --git a/xds/src/test/java/io/grpc/xds/ExtAuthzFilterTest.java b/xds/src/test/java/io/grpc/xds/ExtAuthzFilterTest.java new file mode 100644 index 00000000000..69a9d62f006 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/ExtAuthzFilterTest.java @@ -0,0 +1,554 @@ +/* + * 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; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableList; +import com.google.common.util.concurrent.MoreExecutors; +import io.envoyproxy.envoy.service.auth.v3.AuthorizationGrpc; +import io.envoyproxy.envoy.service.auth.v3.CheckRequest; +import io.envoyproxy.envoy.service.auth.v3.CheckResponse; +import io.grpc.CallOptions; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptor; +import io.grpc.ManagedChannel; +import io.grpc.Metadata; +import io.grpc.ServerCallHandler; +import io.grpc.ServerServiceDefinition; +import io.grpc.Status; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; +import io.grpc.stub.StreamObserver; +import io.grpc.testing.GrpcCleanupRule; +import io.grpc.testing.protobuf.SimpleRequest; +import io.grpc.testing.protobuf.SimpleResponse; +import io.grpc.testing.protobuf.SimpleServiceGrpc; +import io.grpc.xds.internal.extauthz.ExtAuthzConfig; +import io.grpc.xds.internal.grpcservice.GrpcServiceConfig; +import io.grpc.xds.internal.headermutations.HeaderMutator; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +/** + * Unit tests for {@link ExtAuthzFilter} and its nested static {@code ExtAuthzClientInterceptor}. + */ +@RunWith(JUnit4.class) +public class ExtAuthzFilterTest { + + @Rule + public final MockitoRule mocks = MockitoJUnit.rule(); + @Rule + public final GrpcCleanupRule grpcCleanup = new GrpcCleanupRule(); + + @Mock + private ThreadSafeRandom mockRandom; + @Mock + private AuthorizationGrpc.AuthorizationImplBase mockAuthzService; + + private final HeaderMutator headerMutator = HeaderMutator.create(); + private final ScheduledExecutorService scheduler = + Executors.newSingleThreadScheduledExecutor(); + + private ManagedChannel channel; + private CallOptions callOptions = + CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + private volatile Metadata lastBackendHeaders; + + private ExtAuthzFilter filter; + private ExtAuthzConfig extAuthzConfig; + + @Before + public void setUp() throws Exception { + // Single in-process server hosting both authz and backend services + String serverName = InProcessServerBuilder.generateName(); + grpcCleanup.register(InProcessServerBuilder + .forName(serverName) + .directExecutor() + .intercept(new io.grpc.ServerInterceptor() { + @Override + public io.grpc.ServerCall.Listener interceptCall( + io.grpc.ServerCall call, Metadata headers, + ServerCallHandler next) { + lastBackendHeaders = headers; + return next.startCall(call, headers); + } + }) + .addService(mockAuthzService) + .addService(ServerServiceDefinition.builder( + SimpleServiceGrpc.getUnaryRpcMethod().getServiceName()) + .addMethod(SimpleServiceGrpc.getUnaryRpcMethod(), + (ServerCallHandler) (call, headers) -> { + call.sendHeaders(new Metadata()); + call.sendMessage(SimpleResponse.getDefaultInstance()); + call.close(Status.OK, new Metadata()); + return new io.grpc.ServerCall.Listener() {}; + }) + .build()) + .build() + .start()); + channel = grpcCleanup.register(InProcessChannelBuilder + .forName(serverName) + .directExecutor() + .build()); + + ExtAuthzFilter.ChannelProvider channelProvider = + new ExtAuthzFilter.ChannelProvider() { + @Override + public ManagedChannel getChannel(GrpcServiceConfig config) { + return channel; + } + + @Override + public void close() { + channel.shutdownNow(); + } + }; + + filter = new ExtAuthzFilter(channelProvider, mockRandom, headerMutator); + } + + private ExtAuthzConfig buildExtAuthzConfig() { + return buildExtAuthzConfig(100); + } + + private ExtAuthzConfig buildExtAuthzConfig(int percent) { + GrpcServiceConfig.GoogleGrpcConfig googleGrpc = GrpcServiceConfig.GoogleGrpcConfig.builder() + .target("test-cluster") + .configuredChannelCredentials(io.grpc.xds.client.ConfiguredChannelCredentials.create( + mock(io.grpc.ChannelCredentials.class), + mock(io.grpc.xds.client.ConfiguredChannelCredentials.ChannelCredsConfig.class))) + .build(); + + GrpcServiceConfig dummyServiceConfig = GrpcServiceConfig.builder() + .googleGrpc(googleGrpc) + .initialMetadata(ImmutableList.of()) + .build(); + + return ExtAuthzConfig.builder() + .grpcService(dummyServiceConfig) + .includePeerCertificate(true) + .allowedHeaders(ImmutableList.of()) + .disallowedHeaders(ImmutableList.of()) + .failureModeAllow(true) + .failureModeAllowHeaderAdd(false) + .denyAtDisable(false) + .filterEnabled(io.grpc.xds.internal.Matchers.FractionMatcher.create(percent, 100)) + .statusOnError(Status.INTERNAL) + .build(); + } + + @Test + public void buildClientInterceptor_success() { + extAuthzConfig = buildExtAuthzConfig(); + ExtAuthzFilter.ExtAuthzFilterConfig filterConfig = + new ExtAuthzFilter.ExtAuthzFilterConfig(extAuthzConfig); + ClientInterceptor created = filter.buildClientInterceptor(filterConfig, null, scheduler); + assertThat(created).isNotNull(); + } + + @Test + public void buildClientInterceptor_withTimeout_appliesDeadline() { + GrpcServiceConfig.GoogleGrpcConfig googleGrpc = GrpcServiceConfig.GoogleGrpcConfig.builder() + .target("test-cluster") + .configuredChannelCredentials(io.grpc.xds.client.ConfiguredChannelCredentials.create( + mock(io.grpc.ChannelCredentials.class), + mock(io.grpc.xds.client.ConfiguredChannelCredentials.ChannelCredsConfig.class))) + .build(); + GrpcServiceConfig serviceConfigWithTimeout = GrpcServiceConfig.builder() + .googleGrpc(googleGrpc) + .initialMetadata(ImmutableList.of()) + .timeout(java.time.Duration.ofSeconds(5)) + .build(); + ExtAuthzConfig configWithTimeout = ExtAuthzConfig.builder() + .grpcService(serviceConfigWithTimeout) + .includePeerCertificate(true) + .allowedHeaders(ImmutableList.of()) + .disallowedHeaders(ImmutableList.of()) + .failureModeAllow(true) + .failureModeAllowHeaderAdd(false) + .denyAtDisable(false) + .filterEnabled(io.grpc.xds.internal.Matchers.FractionMatcher.create(100, 100)) + .statusOnError(Status.INTERNAL) + .build(); + + ExtAuthzFilter.ExtAuthzFilterConfig filterConfig = + new ExtAuthzFilter.ExtAuthzFilterConfig(configWithTimeout); + ClientInterceptor created = filter.buildClientInterceptor(filterConfig, null, scheduler); + assertThat(created).isInstanceOf(ExtAuthzFilter.ExtAuthzClientInterceptor.class); + ExtAuthzFilter.ExtAuthzClientInterceptor interceptor = + (ExtAuthzFilter.ExtAuthzClientInterceptor) created; + assertThat(interceptor.getAuthzStubForTest().getCallOptions().getDeadline()).isNotNull(); + } + + @Test + public void buildClientInterceptor_withOverride_returnsNull() { + extAuthzConfig = buildExtAuthzConfig(); + ClientInterceptor interceptor = + filter.buildClientInterceptor(new ExtAuthzFilter.ExtAuthzFilterConfig(extAuthzConfig), + new ExtAuthzFilter.ExtAuthzFilterConfigOverride(), scheduler); + assertThat(interceptor).isNull(); + } + + @Test + public void buildClientInterceptor_wrongConfigType_returnsNull() { + ClientInterceptor interceptor = + filter.buildClientInterceptor(mock(Filter.FilterConfig.class), null, scheduler); + assertThat(interceptor).isNull(); + } + + @Test + public void close_shouldShutdownChannel() { + extAuthzConfig = buildExtAuthzConfig(); + filter.buildClientInterceptor( + new ExtAuthzFilter.ExtAuthzFilterConfig(extAuthzConfig), null, scheduler); + filter.close(); + assertThat(channel.isShutdown()).isTrue(); + } + + @Test + public void buildClientInterceptor_withCallCredentials_appliesCallCredentials() { + io.grpc.CallCredentials fakeCallCreds = mock(io.grpc.CallCredentials.class); + GrpcServiceConfig.GoogleGrpcConfig googleGrpc = GrpcServiceConfig.GoogleGrpcConfig.builder() + .target("test-cluster") + .configuredChannelCredentials(io.grpc.xds.client.ConfiguredChannelCredentials.create( + mock(io.grpc.ChannelCredentials.class), + mock(io.grpc.xds.client.ConfiguredChannelCredentials.ChannelCredsConfig.class))) + .callCredentials(fakeCallCreds) + .build(); + GrpcServiceConfig serviceConfig = GrpcServiceConfig.builder() + .googleGrpc(googleGrpc) + .initialMetadata(ImmutableList.of()) + .build(); + ExtAuthzConfig configWithCreds = ExtAuthzConfig.builder() + .grpcService(serviceConfig) + .includePeerCertificate(true) + .allowedHeaders(ImmutableList.of()) + .disallowedHeaders(ImmutableList.of()) + .failureModeAllow(true) + .failureModeAllowHeaderAdd(false) + .denyAtDisable(false) + .filterEnabled(io.grpc.xds.internal.Matchers.FractionMatcher.create(100, 100)) + .statusOnError(Status.INTERNAL) + .build(); + + ExtAuthzFilter.ExtAuthzFilterConfig filterConfig = + new ExtAuthzFilter.ExtAuthzFilterConfig(configWithCreds); + ClientInterceptor created = filter.buildClientInterceptor(filterConfig, null, scheduler); + assertThat(created).isInstanceOf(ExtAuthzFilter.ExtAuthzClientInterceptor.class); + ExtAuthzFilter.ExtAuthzClientInterceptor interceptor = + (ExtAuthzFilter.ExtAuthzClientInterceptor) created; + assertThat(interceptor.getAuthzStubForTest().getCallOptions().getCredentials()) + .isSameInstanceAs(fakeCallCreds); + } + + // ========================================================================== + // ExtAuthzClientInterceptor Specific Unit Tests + // ========================================================================== + + @Test + public void interceptCall_denyAtDisable_failsWithConfiguredStatus() { + when(mockRandom.nextInt(100)).thenReturn(50); + ExtAuthzConfig config = ExtAuthzConfig.builder() + .grpcService(buildExtAuthzConfig().grpcService()) + .includePeerCertificate(true) + .allowedHeaders(ImmutableList.of()) + .disallowedHeaders(ImmutableList.of()) + .failureModeAllow(true) + .failureModeAllowHeaderAdd(false) + .denyAtDisable(true) + .filterEnabled(io.grpc.xds.internal.Matchers.FractionMatcher.create(0, 100)) + .statusOnError(Status.PERMISSION_DENIED) + .build(); + + ExtAuthzFilter.ExtAuthzFilterConfig filterConfig = + new ExtAuthzFilter.ExtAuthzFilterConfig(config); + ClientInterceptor interceptor = filter.buildClientInterceptor(filterConfig, null, scheduler); + assertThat(interceptor).isNotNull(); + + ClientCall call = interceptor.interceptCall( + SimpleServiceGrpc.getUnaryRpcMethod(), callOptions, channel); + CapturingListener listener = new CapturingListener<>(); + call.start(listener, new Metadata()); + + // Verify the call delivers the configured status to the listener + assertThat(listener.closeStatus).isNotNull(); + assertThat(listener.closeStatus.getCode()).isEqualTo(Status.Code.PERMISSION_DENIED); + + // Verify the authz service was NEVER contacted (filter was disabled) + verify(mockAuthzService, never()).check(any(CheckRequest.class), any()); + } + + @Test + public void interceptCall_filterDisabled_delegatesToBackend() { + when(mockRandom.nextInt(100)).thenReturn(50); + ExtAuthzConfig config = ExtAuthzConfig.builder() + .grpcService(buildExtAuthzConfig().grpcService()) + .includePeerCertificate(true) + .allowedHeaders(ImmutableList.of()) + .disallowedHeaders(ImmutableList.of()) + .failureModeAllow(true) + .failureModeAllowHeaderAdd(false) + .denyAtDisable(false) + .filterEnabled(io.grpc.xds.internal.Matchers.FractionMatcher.create(0, 100)) + .statusOnError(Status.INTERNAL) + .build(); + + ExtAuthzFilter.ExtAuthzFilterConfig filterConfig = + new ExtAuthzFilter.ExtAuthzFilterConfig(config); + ClientInterceptor interceptor = filter.buildClientInterceptor(filterConfig, null, scheduler); + assertThat(interceptor).isNotNull(); + + // Drive a real RPC through the interceptor — filter is disabled + ClientCall call = interceptor + .interceptCall(SimpleServiceGrpc.getUnaryRpcMethod(), callOptions, channel); + CapturingListener listener = new CapturingListener<>(); + call.start(listener, new Metadata()); + call.halfClose(); + call.request(1); + + // Assert: RPC completes successfully (reaches backend, bypasses authz) + assertThat(listener.closeStatus).isNotNull(); + assertThat(listener.closeStatus.getCode()).isEqualTo(Status.Code.OK); + + // Assert: Authz service was NEVER contacted + verify(mockAuthzService, never()).check(any(CheckRequest.class), any()); + } + + @Test + public void interceptCall_filterEnabled_authzAllows_rpcSucceeds() { + when(mockRandom.nextInt(100)).thenReturn(50); + extAuthzConfig = buildExtAuthzConfig(); + + // Configure mock authz service to return ALLOW (status code 0 = OK) + doAnswer(invocation -> { + StreamObserver observer = invocation.getArgument(1); + observer.onNext(CheckResponse.newBuilder() + .setStatus(com.google.rpc.Status.newBuilder().setCode(com.google.rpc.Code.OK_VALUE)) + .build()); + observer.onCompleted(); + return null; + }).when(mockAuthzService).check(any(CheckRequest.class), any()); + + // Use buildClientInterceptor which creates real internal components + ExtAuthzFilter.ExtAuthzFilterConfig filterConfig = + new ExtAuthzFilter.ExtAuthzFilterConfig(extAuthzConfig); + ClientInterceptor interceptor = filter.buildClientInterceptor(filterConfig, null, scheduler); + assertThat(interceptor).isNotNull(); + + ClientCall call = interceptor.interceptCall( + SimpleServiceGrpc.getUnaryRpcMethod(), callOptions, channel); + CapturingListener listener = new CapturingListener<>(); + call.start(listener, new Metadata()); + call.halfClose(); + call.request(1); + + // Assert: RPC completes successfully through authz + backend + assertThat(listener.closeStatus).isNotNull(); + assertThat(listener.closeStatus.getCode()).isEqualTo(Status.Code.OK); + + // Assert: Authz service WAS contacted (filter is active) + verify(mockAuthzService).check(any(CheckRequest.class), any()); + } + + @Test + public void interceptCall_filterEnabled_authzDenies_rpcFails() { + when(mockRandom.nextInt(100)).thenReturn(50); + extAuthzConfig = buildExtAuthzConfig(); + + // Configure mock authz service to return DENY (non-OK status) + doAnswer(invocation -> { + StreamObserver observer = invocation.getArgument(1); + observer.onNext(CheckResponse.newBuilder().setStatus( + com.google.rpc.Status.newBuilder().setCode(com.google.rpc.Code.PERMISSION_DENIED_VALUE)) + .build()); + observer.onCompleted(); + return null; + }).when(mockAuthzService).check(any(CheckRequest.class), any()); + + // Use buildClientInterceptor which creates real internal components + ExtAuthzFilter.ExtAuthzFilterConfig filterConfig = + new ExtAuthzFilter.ExtAuthzFilterConfig(extAuthzConfig); + ClientInterceptor interceptor = filter.buildClientInterceptor(filterConfig, null, scheduler); + assertThat(interceptor).isNotNull(); + + ClientCall call = interceptor.interceptCall( + SimpleServiceGrpc.getUnaryRpcMethod(), callOptions, channel); + CapturingListener listener = new CapturingListener<>(); + call.start(listener, new Metadata()); + + // Assert: RPC fails with PERMISSION_DENIED + assertThat(listener.closeStatus).isNotNull(); + assertThat(listener.closeStatus.getCode()).isEqualTo(Status.Code.PERMISSION_DENIED); + + // Assert: Authz service WAS contacted + verify(mockAuthzService).check(any(CheckRequest.class), any()); + } + + @Test + public void interceptCall_nullExecutor_failsWithInternal() { + when(mockRandom.nextInt(100)).thenReturn(50); + extAuthzConfig = buildExtAuthzConfig(); + + // Use buildClientInterceptor which creates real internal components + ExtAuthzFilter.ExtAuthzFilterConfig filterConfig = + new ExtAuthzFilter.ExtAuthzFilterConfig(extAuthzConfig); + ClientInterceptor interceptor = filter.buildClientInterceptor(filterConfig, null, scheduler); + assertThat(interceptor).isNotNull(); + + ClientCall call = interceptor.interceptCall( + SimpleServiceGrpc.getUnaryRpcMethod(), CallOptions.DEFAULT, channel); + CapturingListener listener = new CapturingListener<>(); + call.start(listener, new Metadata()); + assertThat(listener.closeStatus).isNotNull(); + assertThat(listener.closeStatus.getCode()).isEqualTo(Status.Code.INTERNAL); + assertThat(listener.closeStatus.getDescription()).contains("No executor provided"); + } + + // ========================================================================== + // Provider.parseFilterConfig / parseFilterConfigOverride Tests + // ========================================================================== + + private static io.grpc.xds.client.Bootstrapper.BootstrapInfo dummyBootstrapInfo() { + io.grpc.xds.client.Bootstrapper.ServerInfo serverInfo = dummyServerInfo(); + return io.grpc.xds.client.Bootstrapper.BootstrapInfo.builder() + .servers(java.util.Collections.singletonList(serverInfo)) + .node(io.grpc.xds.client.EnvoyProtoData.Node.newBuilder().build()) + .build(); + } + + private static io.grpc.xds.client.Bootstrapper.ServerInfo dummyServerInfo() { + return io.grpc.xds.client.Bootstrapper.ServerInfo.create( + "test_target", java.util.Collections.emptyMap(), false, true, false, false); + } + + private static Filter.FilterConfigParseContext dummyParseContext() { + return Filter.FilterConfigParseContext.builder() + .bootstrapInfo(dummyBootstrapInfo()) + .serverInfo(dummyServerInfo()) + .build(); + } + + @Test + public void parseFilterConfig_validProto_returnsConfig() { + io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz extAuthzProto = + io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz.newBuilder() + .setGrpcService( + io.envoyproxy.envoy.config.core.v3.GrpcService.newBuilder() + .setGoogleGrpc( + io.envoyproxy.envoy.config.core.v3.GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("authz-cluster") + .addChannelCredentialsPlugin( + com.google.protobuf.Any.pack( + io.envoyproxy.envoy.extensions.grpc_service + .channel_credentials.google_default.v3 + .GoogleDefaultCredentials.newBuilder().build())))) + .build(); + com.google.protobuf.Any anyMessage = + com.google.protobuf.Any.pack(extAuthzProto); + + ExtAuthzFilter.Provider provider = new ExtAuthzFilter.Provider(); + ConfigOrError result = + provider.parseFilterConfig(anyMessage, dummyParseContext()); + + assertThat(result.errorDetail).isNull(); + assertThat(result.config) + .isInstanceOf(ExtAuthzFilter.ExtAuthzFilterConfig.class); + } + + @Test + public void parseFilterConfig_invalidProto_returnsError() { + com.google.protobuf.Any anyMessage = com.google.protobuf.Any.newBuilder() + .setTypeUrl( + "type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz") + .setValue(com.google.protobuf.ByteString.copyFromUtf8("not-valid-proto")) + .build(); + + ExtAuthzFilter.Provider provider = new ExtAuthzFilter.Provider(); + ConfigOrError result = + provider.parseFilterConfig(anyMessage, dummyParseContext()); + + assertThat(result.errorDetail).isNotNull(); + assertThat(result.errorDetail).contains("Invalid proto"); + } + + @Test + public void parseFilterConfig_nonAnyMessage_returnsError() { + com.google.protobuf.Message nonAnyMessage = + io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz + .getDefaultInstance(); + + ExtAuthzFilter.Provider provider = new ExtAuthzFilter.Provider(); + ConfigOrError result = + provider.parseFilterConfig(nonAnyMessage, dummyParseContext()); + + assertThat(result.errorDetail).isNotNull(); + assertThat(result.errorDetail).contains("Invalid config type"); + } + + @Test + public void parseFilterConfigOverride_validAny_returnsOverride() { + com.google.protobuf.Any anyMessage = com.google.protobuf.Any.pack( + io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + .getDefaultInstance()); + + ExtAuthzFilter.Provider provider = new ExtAuthzFilter.Provider(); + ConfigOrError result = + provider.parseFilterConfigOverride(anyMessage, dummyParseContext()); + + assertThat(result.errorDetail).isNull(); + assertThat(result.config) + .isInstanceOf(ExtAuthzFilter.ExtAuthzFilterConfigOverride.class); + } + + @Test + public void parseFilterConfigOverride_nonAnyMessage_returnsError() { + com.google.protobuf.Message nonAnyMessage = + io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + .getDefaultInstance(); + + ExtAuthzFilter.Provider provider = new ExtAuthzFilter.Provider(); + ConfigOrError result = + provider.parseFilterConfigOverride(nonAnyMessage, dummyParseContext()); + + assertThat(result.errorDetail).isNotNull(); + assertThat(result.errorDetail).contains("Invalid config type"); + } + + private static class CapturingListener extends ClientCall.Listener { + volatile Status closeStatus; + + @Override + public void onClose(Status status, Metadata trailers) { + this.closeStatus = status; + } + } +}