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; + } + } +}