diff --git a/xds/src/main/java/io/grpc/xds/internal/extauthz/AuthzCallbackObserver.java b/xds/src/main/java/io/grpc/xds/internal/extauthz/AuthzCallbackObserver.java new file mode 100644 index 00000000000..88091cd67d0 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extauthz/AuthzCallbackObserver.java @@ -0,0 +1,172 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.extauthz; + +import com.google.common.collect.ImmutableList; +import io.envoyproxy.envoy.service.auth.v3.CheckResponse; +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.Context; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.Status; +import io.grpc.internal.DelayedClientCall; +import io.grpc.stub.StreamObserver; +import io.grpc.xds.internal.grpcservice.HeaderValue; +import io.grpc.xds.internal.headermutations.HeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderMutator; +import io.grpc.xds.internal.headermutations.HeaderValueOption; +import java.util.concurrent.Executor; + +/** + * A {@link StreamObserver} that handles the response from the external authorization service, + * transitioning the delayed gRPC call based on the authorization decision. + */ +final class AuthzCallbackObserver implements StreamObserver { + + private static final Metadata.Key X_ENVOY_AUTH_FAILURE_MODE_ALLOWED = + Metadata.Key.of("x-envoy-auth-failure-mode-allowed", Metadata.ASCII_STRING_MARSHALLER); + + private final DelayedClientCall delayedCall; + private final Channel next; + private final MethodDescriptor method; + private final CallOptions callOptions; + private final Executor callExecutor; + private final CheckResponseHandler responseHandler; + private final HeaderMutator headerMutator; + private final ExtAuthzConfig config; + private final Context.CancellableContext authzContext; + + AuthzCallbackObserver( + DelayedClientCall delayedCall, + Channel next, + MethodDescriptor method, + CallOptions callOptions, + Executor callExecutor, + CheckResponseHandler responseHandler, + HeaderMutator headerMutator, + ExtAuthzConfig config, + Context.CancellableContext authzContext) { + this.delayedCall = delayedCall; + this.next = next; + this.method = method; + this.callOptions = callOptions; + this.callExecutor = callExecutor; + this.responseHandler = responseHandler; + this.headerMutator = headerMutator; + this.config = config; + this.authzContext = authzContext; + } + + @Override + public void onNext(CheckResponse value) { + // Note on exception safety: handleResponse() internally catches + // HeaderMutationDisallowedException and converts it to a DENY response. + // Remaining calls (newCall, setCallAndDrain) use battle-tested + // infrastructure with protobuf-guaranteed non-null defaults. An + // uncaught RuntimeException here would propagate to the gRPC stub + // framework, which handles observer stream cleanup. The authzContext + // would remain uncancelled, but will be GC'd when this observer is + // collected — acceptable since the authz RPC would have already + // completed (we're inside onNext). + AuthzResponse authzResponse = responseHandler.handleResponse(value); + if (authzResponse.decision() == AuthzResponse.Decision.ALLOW) { + ClientCall delegate = next.newCall(method, callOptions); + ClientCall finalCall; + if (isHeaderMutationsEmpty(authzResponse.requestHeaderMutations()) + && isHeaderMutationsEmpty(authzResponse.responseHeaderMutations())) { + finalCall = delegate; + } else { + finalCall = new MutatingClientCall<>( + delegate, + authzResponse.requestHeaderMutations(), + authzResponse.responseHeaderMutations(), + headerMutator); + } + setCallAndDrain(finalCall); + } else { + // Defensive programming: status is always present on DENY branches since every + // deny() factory method requires a Status parameter. This orElseThrow satisfies + // static analysis and documents the invariant. + Status status = authzResponse.status().orElseThrow( + () -> new IllegalArgumentException("DENY response missing status")); + ClientCall failingCall; + if (isHeaderMutationsEmpty(authzResponse.responseHeaderMutations())) { + failingCall = new FailingClientCall<>(status); + } else { + failingCall = new FailingCallWithTrailerMutations<>( + status, + authzResponse.responseHeaderMutations(), + headerMutator); + } + setCallAndDrain(failingCall); + } + } + + @Override + public void onError(Throwable t) { + if (config.failureModeAllow()) { + ClientCall delegate = next.newCall(method, callOptions); + ClientCall finalCall; + if (config.failureModeAllowHeaderAdd()) { + HeaderMutations requestMutations = HeaderMutations.create( + ImmutableList.of(HeaderValueOption.create( + HeaderValue.create(X_ENVOY_AUTH_FAILURE_MODE_ALLOWED.name(), "true"), + HeaderValueOption.HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD)), + ImmutableList.of()); + // TODO(sauravz): Consider using a static EMPTY_MUTATIONS constant + // to avoid repeated allocation of empty HeaderMutations. + HeaderMutations responseMutations = + HeaderMutations.create(ImmutableList.of(), ImmutableList.of()); + finalCall = new MutatingClientCall<>( + delegate, requestMutations, responseMutations, headerMutator); + } else { + finalCall = delegate; + } + setCallAndDrain(finalCall); + authzContext.close(); + } else { + Status statusToReturn = config.statusOnError().withCause(t); + setCallAndDrain(new FailingClientCall<>(statusToReturn)); + authzContext.close(); + } + } + + @Override + public void onCompleted() { + // Defensive: if the authz server completed without sending a CheckResponse + // (buggy server), resolve the DelayedClientCall with a failure and close + // the authzContext. If onNext() already called setCall(), this is a no-op + // since DelayedClientCall.setCall() ignores subsequent calls. + Status status = config.statusOnError() + .withDescription("Authz server completed without sending CheckResponse"); + setCallAndDrain(new FailingClientCall<>(status)); + authzContext.close(); + } + + private void setCallAndDrain(ClientCall call) { + Runnable drain = delayedCall.setCall(call); + if (drain != null) { + callExecutor.execute(drain); + } + } + + private static boolean isHeaderMutationsEmpty(HeaderMutations mutations) { + return mutations.headers().isEmpty() && mutations.headersToRemove().isEmpty(); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzClientCall.java b/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzClientCall.java new file mode 100644 index 00000000000..b9280def81d --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzClientCall.java @@ -0,0 +1,127 @@ +/* + * 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.annotations.VisibleForTesting; +import com.google.protobuf.util.Timestamps; +import io.envoyproxy.envoy.service.auth.v3.AuthorizationGrpc; +import io.envoyproxy.envoy.service.auth.v3.CheckRequest; +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.Context; +import io.grpc.Deadline; +import io.grpc.ForwardingClientCall; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.internal.DelayedClientCall; +import io.grpc.xds.internal.headermutations.HeaderMutator; +import java.util.concurrent.Executor; +import java.util.concurrent.ScheduledExecutorService; +import javax.annotation.Nullable; + +/** + * A {@link ForwardingClientCall} that delegates to a lightweight {@link DelayedClientCall} + * to buffer RPC requests safely while waiting for the asynchronous authorization decision. + */ +public final class ExtAuthzClientCall extends ForwardingClientCall { + + private final Channel next; + private final MethodDescriptor method; + private final CallOptions callOptions; + private final AuthorizationGrpc.AuthorizationStub authzStub; + private final CheckRequestBuilder checkRequestBuilder; + private final CheckResponseHandler responseHandler; + private final HeaderMutator headerMutator; + private final ExtAuthzConfig config; + private final Executor callExecutor; + private final Context.CancellableContext authzContext; + + private final DelayedAuthzCall delegate; + + public ExtAuthzClientCall( + Executor executor, + ScheduledExecutorService scheduler, + CallOptions callOptions, + Channel next, + MethodDescriptor method, + AuthorizationGrpc.AuthorizationStub authzStub, + CheckRequestBuilder checkRequestBuilder, + CheckResponseHandler responseHandler, + HeaderMutator headerMutator, + ExtAuthzConfig config) { + this.callOptions = callOptions; + this.next = next; + this.method = method; + this.authzStub = authzStub; + this.checkRequestBuilder = checkRequestBuilder; + this.responseHandler = responseHandler; + this.headerMutator = headerMutator; + this.config = config; + this.callExecutor = executor; + this.authzContext = Context.current().withCancellation(); + this.delegate = new DelayedAuthzCall<>(executor, scheduler, callOptions.getDeadline()); + } + + @Override + protected ClientCall delegate() { + return delegate; + } + + @Override + public void start(Listener responseListener, Metadata headers) { + // 1. Construct the check request FIRST before handoff (respects metadata thread safety) + CheckRequest request = checkRequestBuilder.buildRequest(method, headers, + Timestamps.fromMillis(System.currentTimeMillis())); + + // 2. Start the delayed call (buffers headers and outbound payloads) + super.start(responseListener, headers); + + // 3. Trigger the async authorization check under the cancellable context + authzContext.run(() -> { + authzStub.check(request, new AuthzCallbackObserver<>( + delegate, next, method, callOptions, callExecutor, responseHandler, headerMutator, + config, authzContext)); + }); + } + + @Override + public void cancel( + @Nullable String message, @Nullable Throwable cause) { + authzContext.cancel(cause); + super.cancel(message, cause); + } + + /** + * Returns the cancellable context used for the authorization check. + * Visible for testing. + */ + @VisibleForTesting + Context.CancellableContext getAuthzContextForTest() { + return authzContext; + } + + /** + * A lightweight package-private DelayedClientCall subclass to expose + * the protected constructor. + */ + private static final class DelayedAuthzCall extends DelayedClientCall { + DelayedAuthzCall(Executor executor, ScheduledExecutorService scheduler, Deadline deadline) { + super("ExtAuthzClientCall", executor, scheduler, deadline); + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/extauthz/FailingCallWithTrailerMutations.java b/xds/src/main/java/io/grpc/xds/internal/extauthz/FailingCallWithTrailerMutations.java new file mode 100644 index 00000000000..407cc506b2d --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extauthz/FailingCallWithTrailerMutations.java @@ -0,0 +1,61 @@ +/* + * 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 io.grpc.ClientCall; +import io.grpc.Metadata; +import io.grpc.Status; +import io.grpc.xds.internal.headermutations.HeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderMutator; + +/** + * A simple failing client call that lazily applies response mutations to trailers during start. + */ +final class FailingCallWithTrailerMutations extends ClientCall { + private final Status status; + private final HeaderMutations responseHeaderMutations; + private final HeaderMutator headerMutator; + + FailingCallWithTrailerMutations( + Status status, + HeaderMutations responseHeaderMutations, + HeaderMutator headerMutator) { + this.status = status; + this.responseHeaderMutations = responseHeaderMutations; + this.headerMutator = headerMutator; + } + + @Override + public void start(Listener responseListener, Metadata headers) { + // Lazily allocate and apply response mutations to trailers copy on start! + Metadata trailers = new Metadata(); + headerMutator.applyMutations(responseHeaderMutations, trailers); + responseListener.onClose(status, trailers); + } + + @Override + public void request(int numMessages) {} + + @Override + public void cancel(String message, Throwable cause) {} + + @Override + public void halfClose() {} + + @Override + public void sendMessage(ReqT message) {} +} diff --git a/xds/src/main/java/io/grpc/xds/internal/extauthz/FailingClientCall.java b/xds/src/main/java/io/grpc/xds/internal/extauthz/FailingClientCall.java new file mode 100644 index 00000000000..9e0f2e6575b --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extauthz/FailingClientCall.java @@ -0,0 +1,57 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.extauthz; + +import io.grpc.ClientCall; +import io.grpc.Metadata; +import io.grpc.Status; +import javax.annotation.Nullable; + +/** + * A {@link ClientCall} that fails immediately upon starting. + */ +public final class FailingClientCall extends ClientCall { + + private final Status error; + + /** + * Creates a new call that will fail with the given error. + */ + public FailingClientCall(Status error) { + this.error = error; + } + + /** + * Immediately fails the call by calling {@link Listener#onClose}. + */ + @Override + public void start(Listener responseListener, Metadata headers) { + responseListener.onClose(error, new Metadata()); + } + + @Override + public void request(int numMessages) {} + + @Override + public void cancel(@Nullable String message, @Nullable Throwable cause) {} + + @Override + public void halfClose() {} + + @Override + public void sendMessage(ReqT message) {} +} diff --git a/xds/src/main/java/io/grpc/xds/internal/extauthz/MutatingClientCall.java b/xds/src/main/java/io/grpc/xds/internal/extauthz/MutatingClientCall.java new file mode 100644 index 00000000000..8503530ce1b --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extauthz/MutatingClientCall.java @@ -0,0 +1,70 @@ +/* + * 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 io.grpc.ClientCall; +import io.grpc.ForwardingClientCall; +import io.grpc.ForwardingClientCallListener; +import io.grpc.Metadata; +import io.grpc.xds.internal.headermutations.HeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderMutator; + +/** + * A {@link ForwardingClientCall} that unifies both request and response header mutations + * symmetrically. + */ +final class MutatingClientCall + extends ForwardingClientCall.SimpleForwardingClientCall { + + private final HeaderMutations requestHeaderMutations; + private final HeaderMutations responseHeaderMutations; + private final HeaderMutator headerMutator; + + MutatingClientCall( + ClientCall delegate, + HeaderMutations requestHeaderMutations, + HeaderMutations responseHeaderMutations, + HeaderMutator headerMutator) { + super(delegate); + this.requestHeaderMutations = requestHeaderMutations; + this.responseHeaderMutations = responseHeaderMutations; + this.headerMutator = headerMutator; + } + + @Override + public void start(Listener responseListener, Metadata headers) { + // 1. Apply allowed request header mutations lazily before forwarding start! + headerMutator.applyMutations(requestHeaderMutations, headers); + + if (responseHeaderMutations.headers().isEmpty() + && responseHeaderMutations.headersToRemove().isEmpty()) { + super.start(responseListener, headers); + return; + } + + Listener wrappedListener = new ForwardingClientCallListener + .SimpleForwardingClientCallListener(responseListener) { + @Override + public void onHeaders(Metadata headers) { + // 2. Apply allowed response header mutations when headers are returned by the server + headerMutator.applyMutations(responseHeaderMutations, headers); + super.onHeaders(headers); + } + }; + super.start(wrappedListener, headers); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/extauthz/AuthzCallbackObserverTest.java b/xds/src/test/java/io/grpc/xds/internal/extauthz/AuthzCallbackObserverTest.java new file mode 100644 index 00000000000..166e68d97c7 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/extauthz/AuthzCallbackObserverTest.java @@ -0,0 +1,720 @@ +/* + * 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.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableList; +import com.google.common.util.concurrent.MoreExecutors; +import io.envoyproxy.envoy.config.core.v3.HeaderValue; +import io.envoyproxy.envoy.config.core.v3.HeaderValueOption; +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.envoyproxy.envoy.service.auth.v3.DeniedHttpResponse; +import io.envoyproxy.envoy.service.auth.v3.OkHttpResponse; +import io.envoyproxy.envoy.type.v3.HttpStatus; +import io.envoyproxy.envoy.type.v3.StatusCode; +import io.grpc.CallOptions; +import io.grpc.ChannelCredentials; +import io.grpc.Context; +import io.grpc.Deadline; +import io.grpc.ManagedChannel; +import io.grpc.Metadata; +import io.grpc.Server; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.ServerServiceDefinition; +import io.grpc.Status; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; +import io.grpc.internal.DelayedClientCall; +import io.grpc.stub.StreamObserver; +import io.grpc.testing.protobuf.SimpleRequest; +import io.grpc.testing.protobuf.SimpleResponse; +import io.grpc.testing.protobuf.SimpleServiceGrpc; +import io.grpc.xds.client.ConfiguredChannelCredentials; +import io.grpc.xds.internal.Matchers; +import io.grpc.xds.internal.extauthz.ExtAuthzTestHelper.CapturingListener; +import io.grpc.xds.internal.grpcservice.GrpcServiceConfig; +import io.grpc.xds.internal.headermutations.HeaderMutationFilter; +import io.grpc.xds.internal.headermutations.HeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderMutator; +import java.util.Optional; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import org.junit.After; +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; + +/** + * Behavioral unit tests for {@link AuthzCallbackObserver}. + */ +@RunWith(JUnit4.class) +public class AuthzCallbackObserverTest { + + @Rule + public final MockitoRule mocks = MockitoJUnit.rule(); + + @Mock + private AuthorizationGrpc.AuthorizationImplBase authzService; + + private final CheckResponseHandler responseHandler = + new CheckResponseHandler(new HeaderMutationFilter(Optional.empty())); + private final HeaderMutator headerMutator = HeaderMutator.create(); + private final ScheduledExecutorService scheduler = + Executors.newSingleThreadScheduledExecutor(); + + private Server server; + private ManagedChannel channel; + private volatile Metadata capturedBackendHeaders; + private volatile SimpleRequest capturedBackendMessage; + + @Before + public void setUp() throws Exception { + String serverName = InProcessServerBuilder.generateName(); + server = InProcessServerBuilder.forName(serverName) + .directExecutor() + .addService(authzService) + .intercept(new ServerInterceptor() { + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, + ServerCallHandler next) { + if (call.getMethodDescriptor().getServiceName().equals( + SimpleServiceGrpc.getServiceDescriptor().getName())) { + capturedBackendHeaders = headers; + } + return next.startCall(call, headers); + } + }) + .addService(ServerServiceDefinition.builder( + SimpleServiceGrpc.getUnaryRpcMethod().getServiceName()) + .addMethod(SimpleServiceGrpc.getUnaryRpcMethod(), + (ServerCallHandler) (call, headers) -> { + call.request(2); + return new ServerCall.Listener() { + @Override + public void onMessage(SimpleRequest message) { + capturedBackendMessage = message; + call.sendHeaders(new Metadata()); + call.sendMessage(SimpleResponse.getDefaultInstance()); + call.close(Status.OK, new Metadata()); + } + }; + }) + .build()) + .build().start(); + channel = InProcessChannelBuilder.forName(serverName) + .directExecutor().build(); + } + + @After + public void tearDown() { + if (channel != null) { + channel.shutdownNow(); + } + if (server != null) { + server.shutdownNow(); + } + scheduler.shutdownNow(); + } + + @Test + public void allow_proxiesCallAndMessageToBackend() { + capturedBackendHeaders = null; + capturedBackendMessage = null; + doAnswer(invocation -> { + StreamObserver obs = invocation.getArgument(1); + obs.onNext(CheckResponse.newBuilder() + .setStatus(com.google.rpc.Status.newBuilder().setCode(0).build()) + .setOkResponse(OkHttpResponse.getDefaultInstance()) + .build()); + obs.onCompleted(); + return null; + }).when(authzService).check(any(), any()); + + TestDelayedCall delayedCall = + new TestDelayedCall<>(MoreExecutors.directExecutor(), scheduler, null); + Context.CancellableContext authzCtx = Context.current().withCancellation(); + AuthzCallbackObserver observer = + new AuthzCallbackObserver<>( + delayedCall, channel, + SimpleServiceGrpc.getUnaryRpcMethod(), + CallOptions.DEFAULT, + MoreExecutors.directExecutor(), + responseHandler, headerMutator, failClosedConfig(), authzCtx); + + SimpleRequest request = + SimpleRequest.newBuilder().setRequestMessage("allow-payload").build(); + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("x-request-id", Metadata.ASCII_STRING_MARSHALLER), "req-123"); + + CapturingListener listener = new CapturingListener<>(); + delayedCall.start(listener, headers); + delayedCall.sendMessage(request); + delayedCall.halfClose(); + delayedCall.request(1); + + authzCtx.run(() -> { + AuthorizationGrpc.newStub(channel) + .check(CheckRequest.getDefaultInstance(), observer); + }); + + assertThat(capturedBackendHeaders).isNotNull(); + assertThat(capturedBackendHeaders.get( + Metadata.Key.of("x-request-id", Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("req-123"); + assertThat(capturedBackendMessage).isEqualTo(request); + } + + @Test + public void allow_withHeaderMutations_backendReceivesMutatedHeadersAndMessage() { + capturedBackendHeaders = null; + capturedBackendMessage = null; + doAnswer(invocation -> { + StreamObserver obs = invocation.getArgument(1); + obs.onNext(CheckResponse.newBuilder() + .setStatus(com.google.rpc.Status.newBuilder().setCode(0).build()) + .setOkResponse(OkHttpResponse.newBuilder() + .addHeaders(HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey("x-custom").setValue("injected"))) + .build()) + .build()); + obs.onCompleted(); + return null; + }).when(authzService).check(any(), any()); + + TestDelayedCall delayedCall = + new TestDelayedCall<>(MoreExecutors.directExecutor(), scheduler, null); + Context.CancellableContext authzCtx = Context.current().withCancellation(); + AuthzCallbackObserver observer = + new AuthzCallbackObserver<>( + delayedCall, channel, + SimpleServiceGrpc.getUnaryRpcMethod(), + CallOptions.DEFAULT, + MoreExecutors.directExecutor(), + responseHandler, headerMutator, failClosedConfig(), authzCtx); + + SimpleRequest request = + SimpleRequest.newBuilder().setRequestMessage("mutated-header-payload").build(); + CapturingListener listener = new CapturingListener<>(); + delayedCall.start(listener, new Metadata()); + delayedCall.sendMessage(request); + delayedCall.halfClose(); + delayedCall.request(1); + + authzCtx.run(() -> { + AuthorizationGrpc.newStub(channel) + .check(CheckRequest.getDefaultInstance(), observer); + }); + + assertThat(capturedBackendHeaders).isNotNull(); + assertThat(capturedBackendHeaders.get( + Metadata.Key.of("x-custom", Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("injected"); + assertThat(capturedBackendMessage).isEqualTo(request); + } + + @Test + public void deny_returnsPermissionDeniedWithoutContactingBackend() { + capturedBackendHeaders = null; + capturedBackendMessage = null; + doAnswer(invocation -> { + StreamObserver obs = invocation.getArgument(1); + obs.onNext(CheckResponse.newBuilder() + .setStatus(com.google.rpc.Status.newBuilder() + .setCode(com.google.rpc.Code.PERMISSION_DENIED_VALUE)) + .setDeniedResponse(DeniedHttpResponse.newBuilder() + .setStatus(HttpStatus.newBuilder().setCode(StatusCode.Forbidden))) + .build()); + obs.onCompleted(); + return null; + }).when(authzService).check(any(), any()); + + TestDelayedCall delayedCall = + new TestDelayedCall<>(MoreExecutors.directExecutor(), scheduler, null); + Context.CancellableContext authzCtx = Context.current().withCancellation(); + AuthzCallbackObserver observer = + new AuthzCallbackObserver<>( + delayedCall, channel, + SimpleServiceGrpc.getUnaryRpcMethod(), + CallOptions.DEFAULT, + MoreExecutors.directExecutor(), + responseHandler, headerMutator, failClosedConfig(), authzCtx); + + SimpleRequest request = + SimpleRequest.newBuilder().setRequestMessage("deny-payload").build(); + CapturingListener listener = new CapturingListener<>(); + delayedCall.start(listener, new Metadata()); + delayedCall.sendMessage(request); + delayedCall.halfClose(); + delayedCall.request(1); + + authzCtx.run(() -> { + AuthorizationGrpc.newStub(channel) + .check(CheckRequest.getDefaultInstance(), observer); + }); + + assertThat(listener.getCloseStatus().getCode()) + .isEqualTo(Status.Code.PERMISSION_DENIED); + assertThat(capturedBackendHeaders).isNull(); + assertThat(capturedBackendMessage).isNull(); + } + + @Test + public void deny_withTrailerMutations_clientReceivesMutatedTrailers() { + doAnswer(invocation -> { + StreamObserver obs = invocation.getArgument(1); + obs.onNext(CheckResponse.newBuilder() + .setStatus(com.google.rpc.Status.newBuilder() + .setCode(com.google.rpc.Code.PERMISSION_DENIED_VALUE)) + .setDeniedResponse(DeniedHttpResponse.newBuilder() + .setStatus(HttpStatus.newBuilder().setCode(StatusCode.Forbidden)) + .addHeaders(HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey("x-deny-reason").setValue("policy")))) + .build()); + obs.onCompleted(); + return null; + }).when(authzService).check(any(), any()); + + TestDelayedCall delayedCall = + new TestDelayedCall<>(MoreExecutors.directExecutor(), scheduler, null); + Context.CancellableContext authzCtx = Context.current().withCancellation(); + AuthzCallbackObserver observer = + new AuthzCallbackObserver<>( + delayedCall, channel, + SimpleServiceGrpc.getUnaryRpcMethod(), + CallOptions.DEFAULT, + MoreExecutors.directExecutor(), + responseHandler, headerMutator, failClosedConfig(), authzCtx); + + SimpleRequest request = + SimpleRequest.newBuilder().setRequestMessage("deny-trailers-payload").build(); + CapturingListener listener = new CapturingListener<>(); + delayedCall.start(listener, new Metadata()); + delayedCall.sendMessage(request); + delayedCall.halfClose(); + delayedCall.request(1); + + authzCtx.run(() -> { + AuthorizationGrpc.newStub(channel) + .check(CheckRequest.getDefaultInstance(), observer); + }); + + assertThat(listener.getCloseStatus().getCode()) + .isEqualTo(Status.Code.PERMISSION_DENIED); + assertThat(listener.getCloseTrailers().get( + Metadata.Key.of("x-deny-reason", Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("policy"); + } + + @Test + public void authzError_failClosed_returnsConfiguredStatus() { + capturedBackendHeaders = null; + capturedBackendMessage = null; + doAnswer(invocation -> { + StreamObserver obs = invocation.getArgument(1); + obs.onError(Status.UNAVAILABLE.asRuntimeException()); + return null; + }).when(authzService).check(any(), any()); + + TestDelayedCall delayedCall = + new TestDelayedCall<>(MoreExecutors.directExecutor(), scheduler, null); + Context.CancellableContext authzCtx = Context.current().withCancellation(); + AuthzCallbackObserver observer = + new AuthzCallbackObserver<>( + delayedCall, channel, + SimpleServiceGrpc.getUnaryRpcMethod(), + CallOptions.DEFAULT, + MoreExecutors.directExecutor(), + responseHandler, headerMutator, failClosedConfig(), authzCtx); + + SimpleRequest request = + SimpleRequest.newBuilder().setRequestMessage("error-closed-payload").build(); + CapturingListener listener = new CapturingListener<>(); + delayedCall.start(listener, new Metadata()); + delayedCall.sendMessage(request); + delayedCall.halfClose(); + delayedCall.request(1); + + authzCtx.run(() -> { + AuthorizationGrpc.newStub(channel) + .check(CheckRequest.getDefaultInstance(), observer); + }); + + assertThat(listener.getCloseStatus().getCode()) + .isEqualTo(Status.Code.PERMISSION_DENIED); + assertThat(capturedBackendHeaders).isNull(); + assertThat(capturedBackendMessage).isNull(); + } + + @Test + public void authzError_failOpen_proxiesToBackendWithMessage() { + capturedBackendHeaders = null; + capturedBackendMessage = null; + doAnswer(invocation -> { + StreamObserver obs = invocation.getArgument(1); + obs.onError(Status.UNAVAILABLE.asRuntimeException()); + return null; + }).when(authzService).check(any(), any()); + + TestDelayedCall delayedCall = + new TestDelayedCall<>(MoreExecutors.directExecutor(), scheduler, null); + Context.CancellableContext authzCtx = Context.current().withCancellation(); + AuthzCallbackObserver observer = + new AuthzCallbackObserver<>( + delayedCall, channel, + SimpleServiceGrpc.getUnaryRpcMethod(), + CallOptions.DEFAULT, + MoreExecutors.directExecutor(), + responseHandler, headerMutator, + failOpenConfig(/*headerAdd=*/false), authzCtx); + + SimpleRequest request = + SimpleRequest.newBuilder().setRequestMessage("error-open-payload").build(); + CapturingListener listener = new CapturingListener<>(); + delayedCall.start(listener, new Metadata()); + delayedCall.sendMessage(request); + delayedCall.halfClose(); + delayedCall.request(1); + + authzCtx.run(() -> { + AuthorizationGrpc.newStub(channel) + .check(CheckRequest.getDefaultInstance(), observer); + }); + + assertThat(capturedBackendHeaders).isNotNull(); + assertThat(capturedBackendMessage).isEqualTo(request); + } + + @Test + public void authzError_failOpenWithHeaderAdd_backendGetsFailureModeHeader() { + capturedBackendHeaders = null; + capturedBackendMessage = null; + doAnswer(invocation -> { + StreamObserver obs = invocation.getArgument(1); + obs.onError(Status.UNAVAILABLE.asRuntimeException()); + return null; + }).when(authzService).check(any(), any()); + + TestDelayedCall delayedCall = + new TestDelayedCall<>(MoreExecutors.directExecutor(), scheduler, null); + Context.CancellableContext authzCtx = Context.current().withCancellation(); + AuthzCallbackObserver observer = + new AuthzCallbackObserver<>( + delayedCall, channel, + SimpleServiceGrpc.getUnaryRpcMethod(), + CallOptions.DEFAULT, + MoreExecutors.directExecutor(), + responseHandler, headerMutator, + failOpenConfig(/*headerAdd=*/true), authzCtx); + + SimpleRequest request = + SimpleRequest.newBuilder().setRequestMessage("error-header-payload").build(); + CapturingListener listener = new CapturingListener<>(); + delayedCall.start(listener, new Metadata()); + delayedCall.sendMessage(request); + delayedCall.halfClose(); + delayedCall.request(1); + + authzCtx.run(() -> { + AuthorizationGrpc.newStub(channel) + .check(CheckRequest.getDefaultInstance(), observer); + }); + + assertThat(capturedBackendHeaders).isNotNull(); + assertThat(capturedBackendHeaders.get( + Metadata.Key.of("x-envoy-auth-failure-mode-allowed", + Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("true"); + assertThat(capturedBackendMessage).isEqualTo(request); + } + + @Test + public void buggyServer_completesWithoutResponse_failsCall() { + doAnswer(invocation -> { + StreamObserver obs = invocation.getArgument(1); + obs.onCompleted(); + return null; + }).when(authzService).check(any(), any()); + + TestDelayedCall delayedCall = + new TestDelayedCall<>(MoreExecutors.directExecutor(), scheduler, null); + Context.CancellableContext authzCtx = Context.current().withCancellation(); + AuthzCallbackObserver observer = + new AuthzCallbackObserver<>( + delayedCall, channel, + SimpleServiceGrpc.getUnaryRpcMethod(), + CallOptions.DEFAULT, + MoreExecutors.directExecutor(), + responseHandler, headerMutator, failClosedConfig(), authzCtx); + + SimpleRequest request = + SimpleRequest.newBuilder().setRequestMessage("buggy-server-payload").build(); + CapturingListener listener = new CapturingListener<>(); + delayedCall.start(listener, new Metadata()); + delayedCall.sendMessage(request); + delayedCall.halfClose(); + delayedCall.request(1); + + authzCtx.run(() -> { + AuthorizationGrpc.newStub(channel) + .check(CheckRequest.getDefaultInstance(), observer); + }); + + assertThat(listener.getCloseStatus().isOk()).isFalse(); + assertThat(listener.getCloseStatus().getCode()) + .isEqualTo(Status.Code.PERMISSION_DENIED); + assertThat(listener.getCloseStatus().getCause()).isNotNull(); + assertThat(listener.getCloseStatus().getCause().getMessage()) + .contains("server cancelled stream"); + } + + @Test + public void allow_withResponseOnlyHeaderMutations_backendReceivesMutatedHeadersAndMessage() { + capturedBackendHeaders = null; + capturedBackendMessage = null; + doAnswer(invocation -> { + StreamObserver obs = invocation.getArgument(1); + obs.onNext(CheckResponse.newBuilder() + .setStatus(com.google.rpc.Status.newBuilder().setCode(0).build()) + .setOkResponse(OkHttpResponse.newBuilder() + .addResponseHeadersToAdd(HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey("x-resp-header").setValue("val")))) + .build()); + obs.onCompleted(); + return null; + }).when(authzService).check(any(), any()); + + TestDelayedCall delayedCall = + new TestDelayedCall<>(MoreExecutors.directExecutor(), scheduler, null); + Context.CancellableContext authzCtx = Context.current().withCancellation(); + AuthzCallbackObserver observer = + new AuthzCallbackObserver<>( + delayedCall, channel, + SimpleServiceGrpc.getUnaryRpcMethod(), + CallOptions.DEFAULT, + MoreExecutors.directExecutor(), + responseHandler, headerMutator, failClosedConfig(), authzCtx); + + SimpleRequest request = + SimpleRequest.newBuilder().setRequestMessage("resp-header-payload").build(); + CapturingListener listener = new CapturingListener<>(); + delayedCall.start(listener, new Metadata()); + delayedCall.sendMessage(request); + delayedCall.halfClose(); + delayedCall.request(1); + + authzCtx.run(() -> { + AuthorizationGrpc.newStub(channel) + .check(CheckRequest.getDefaultInstance(), observer); + }); + + assertThat(capturedBackendHeaders).isNotNull(); + assertThat(capturedBackendMessage).isEqualTo(request); + } + + @Test + public void allow_withHeadersToRemoveOnly_backendReceivesMutatedHeaders() { + capturedBackendHeaders = null; + capturedBackendMessage = null; + doAnswer(invocation -> { + StreamObserver obs = invocation.getArgument(1); + obs.onNext(CheckResponse.newBuilder() + .setStatus(com.google.rpc.Status.newBuilder().setCode(0).build()) + .setOkResponse(OkHttpResponse.newBuilder() + .addHeadersToRemove("header-to-remove")) + .build()); + obs.onCompleted(); + return null; + }).when(authzService).check(any(), any()); + + TestDelayedCall delayedCall = + new TestDelayedCall<>(MoreExecutors.directExecutor(), scheduler, null); + Context.CancellableContext authzCtx = Context.current().withCancellation(); + AuthzCallbackObserver observer = + new AuthzCallbackObserver<>( + delayedCall, channel, + SimpleServiceGrpc.getUnaryRpcMethod(), + CallOptions.DEFAULT, + MoreExecutors.directExecutor(), + responseHandler, headerMutator, failClosedConfig(), authzCtx); + + SimpleRequest request = + SimpleRequest.newBuilder().setRequestMessage("remove-header-payload").build(); + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("header-to-remove", Metadata.ASCII_STRING_MARSHALLER), "val"); + CapturingListener listener = new CapturingListener<>(); + delayedCall.start(listener, headers); + delayedCall.sendMessage(request); + delayedCall.halfClose(); + delayedCall.request(1); + + authzCtx.run(() -> { + AuthorizationGrpc.newStub(channel) + .check(CheckRequest.getDefaultInstance(), observer); + }); + + assertThat(capturedBackendHeaders).isNotNull(); + assertThat(capturedBackendHeaders.containsKey( + Metadata.Key.of("header-to-remove", Metadata.ASCII_STRING_MARSHALLER))).isFalse(); + assertThat(capturedBackendMessage).isEqualTo(request); + } + + @Test(expected = IllegalArgumentException.class) + public void deny_withMissingStatus_throwsIllegalArgumentException() { + CheckResponseHandler mockHandler = mock(CheckResponseHandler.class); + AuthzResponse fakeAuthzResponse = new AuthzResponse() { + @Override + public Decision decision() { + return Decision.DENY; + } + + @Override + public Optional status() { + return Optional.empty(); + } + + @Override + public HeaderMutations requestHeaderMutations() { + return HeaderMutations.create(ImmutableList.of(), ImmutableList.of()); + } + + @Override + public HeaderMutations responseHeaderMutations() { + return HeaderMutations.create(ImmutableList.of(), ImmutableList.of()); + } + }; + when(mockHandler.handleResponse(any())).thenReturn(fakeAuthzResponse); + + TestDelayedCall delayedCall = + new TestDelayedCall<>(MoreExecutors.directExecutor(), scheduler, null); + Context.CancellableContext authzCtx = Context.current().withCancellation(); + AuthzCallbackObserver observer = + new AuthzCallbackObserver<>( + delayedCall, channel, + SimpleServiceGrpc.getUnaryRpcMethod(), + CallOptions.DEFAULT, + MoreExecutors.directExecutor(), + mockHandler, headerMutator, failClosedConfig(), authzCtx); + + observer.onNext(CheckResponse.getDefaultInstance()); + } + + @Test + public void allow_whenDelayedCallNotStarted_setCallReturnsNull() { + doAnswer(invocation -> { + StreamObserver obs = invocation.getArgument(1); + obs.onNext(CheckResponse.newBuilder() + .setStatus(com.google.rpc.Status.newBuilder().setCode(0).build()) + .setOkResponse(OkHttpResponse.getDefaultInstance()) + .build()); + obs.onCompleted(); + return null; + }).when(authzService).check(any(), any()); + + TestDelayedCall delayedCall = + new TestDelayedCall<>(MoreExecutors.directExecutor(), scheduler, null); + Context.CancellableContext authzCtx = Context.current().withCancellation(); + AuthzCallbackObserver observer = + new AuthzCallbackObserver<>( + delayedCall, channel, + SimpleServiceGrpc.getUnaryRpcMethod(), + CallOptions.DEFAULT, + MoreExecutors.directExecutor(), + responseHandler, headerMutator, failClosedConfig(), authzCtx); + + authzCtx.run(() -> { + AuthorizationGrpc.newStub(channel) + .check(CheckRequest.getDefaultInstance(), observer); + }); + + assertThat(capturedBackendHeaders).isNull(); + } + + private static final class TestDelayedCall + extends DelayedClientCall { + TestDelayedCall( + java.util.concurrent.Executor executor, + ScheduledExecutorService scheduler, + Deadline deadline) { + super("TestDelayedCall", executor, scheduler, deadline); + } + } + + private static ExtAuthzConfig failClosedConfig() { + GrpcServiceConfig.GoogleGrpcConfig googleGrpc = + GrpcServiceConfig.GoogleGrpcConfig.builder() + .target("test-cluster") + .configuredChannelCredentials( + ConfiguredChannelCredentials.create( + mock(ChannelCredentials.class), + mock(ConfiguredChannelCredentials.ChannelCredsConfig.class))) + .build(); + GrpcServiceConfig grpcServiceConfig = + GrpcServiceConfig.builder() + .googleGrpc(googleGrpc) + .initialMetadata(ImmutableList.of()) + .build(); + return ExtAuthzConfig.builder() + .grpcService(grpcServiceConfig) + .failureModeAllow(false) + .failureModeAllowHeaderAdd(false) + .includePeerCertificate(false) + .denyAtDisable(false) + .filterEnabled(Matchers.FractionMatcher.create(100, 100)) + .statusOnError(Status.PERMISSION_DENIED) + .build(); + } + + private static ExtAuthzConfig failOpenConfig(boolean headerAdd) { + GrpcServiceConfig.GoogleGrpcConfig googleGrpc = + GrpcServiceConfig.GoogleGrpcConfig.builder() + .target("test-cluster") + .configuredChannelCredentials( + ConfiguredChannelCredentials.create( + mock(ChannelCredentials.class), + mock(ConfiguredChannelCredentials.ChannelCredsConfig.class))) + .build(); + GrpcServiceConfig grpcServiceConfig = + GrpcServiceConfig.builder() + .googleGrpc(googleGrpc) + .initialMetadata(ImmutableList.of()) + .build(); + return ExtAuthzConfig.builder() + .grpcService(grpcServiceConfig) + .failureModeAllow(true) + .failureModeAllowHeaderAdd(headerAdd) + .includePeerCertificate(false) + .denyAtDisable(false) + .filterEnabled(Matchers.FractionMatcher.create(100, 100)) + .statusOnError(Status.PERMISSION_DENIED) + .build(); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzClientCallTest.java b/xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzClientCallTest.java new file mode 100644 index 00000000000..ffe0ea2c7a3 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzClientCallTest.java @@ -0,0 +1,630 @@ +/* + * 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.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableList; +import io.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.Channel; +import io.grpc.ChannelCredentials; +import io.grpc.Context; +import io.grpc.ManagedChannel; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.Server; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.ServerServiceDefinition; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; +import io.grpc.stub.StreamObserver; +import io.grpc.testing.protobuf.SimpleRequest; +import io.grpc.testing.protobuf.SimpleResponse; +import io.grpc.testing.protobuf.SimpleServiceGrpc; +import io.grpc.xds.client.ConfiguredChannelCredentials; +import io.grpc.xds.internal.Matchers; +import io.grpc.xds.internal.extauthz.ExtAuthzTestHelper.CapturingListener; +import io.grpc.xds.internal.grpcservice.GrpcServiceConfig; +import io.grpc.xds.internal.grpcservice.HeaderValue; +import io.grpc.xds.internal.headermutations.HeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderMutator; +import io.grpc.xds.internal.headermutations.HeaderValueOption; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import org.junit.After; +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.ArgumentMatchers; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +/** Unit tests for {@link ExtAuthzClientCall}. */ +@RunWith(JUnit4.class) +public class ExtAuthzClientCallTest { + + @Rule + public final MockitoRule mocks = MockitoJUnit.rule(); + + @Mock + private CheckRequestBuilder mockCheckRequestBuilder; + @Mock + private CheckResponseHandler mockResponseHandler; + @Mock + private HeaderMutator mockHeaderMutator; + @Mock + private AuthorizationGrpc.AuthorizationImplBase authzService; + + private final ScheduledExecutorService scheduler = + Executors.newSingleThreadScheduledExecutor(); + private Server server; + private ManagedChannel channel; + private volatile Metadata lastBackendHeaders; + private ExtAuthzConfig config; + private CallOptions callOptions; + + @Before + public void setUp() throws Exception { + // Single in-process server hosting both authz and backend services + String serverName = + InProcessServerBuilder.generateName(); + server = InProcessServerBuilder + .forName(serverName) + .directExecutor() + .intercept(new ServerInterceptor() { + @Override + public io.grpc.ServerCall.Listener interceptCall( + io.grpc.ServerCall call, Metadata headers, + ServerCallHandler next) { + lastBackendHeaders = headers; + return next.startCall(call, headers); + } + }) + .addService(authzService) + .addService(ServerServiceDefinition.builder( + SimpleServiceGrpc.getUnaryRpcMethod().getServiceName()) + .addMethod(SimpleServiceGrpc.getUnaryRpcMethod(), + (ServerCallHandler) (call, headers) -> { + call.sendHeaders(new Metadata()); + call.close(Status.OK, new Metadata()); + return new io.grpc.ServerCall.Listener() {}; + }) + .build()) + .build() + .start(); + channel = InProcessChannelBuilder + .forName(serverName) + .directExecutor() + .build(); + + config = buildConfig(); + callOptions = CallOptions.DEFAULT.withExecutor(scheduler); + when(mockCheckRequestBuilder.buildRequest( + any(MethodDescriptor.class), any(Metadata.class), + any(com.google.protobuf.Timestamp.class))) + .thenReturn(CheckRequest.getDefaultInstance()); + } + + @After + public void tearDown() { + if (server != null) { + server.shutdownNow(); + } + if (channel != null) { + channel.shutdownNow(); + } + scheduler.shutdownNow(); + } + + @Test + public void cancel_cancelsAuthzContext() { + ExtAuthzClientCall call = + createCall(); + + Context.CancellableContext authzContext = + call.getAuthzContextForTest(); + assertThat(authzContext.isCancelled()).isFalse(); + + call.cancel("user cancelled", null); + + assertThat(authzContext.isCancelled()).isTrue(); + } + + @Test + public void cancel_whileCheckInFlight_cancelsContext() { + CountDownLatch checkCalled = new CountDownLatch(1); + doAnswer(invocation -> { + checkCalled.countDown(); + // Don't respond to simulate in-flight check + return null; + }).when(authzService) + .check(any(CheckRequest.class), + ArgumentMatchers.any()); + + ExtAuthzClientCall call = + createCall(); + + CapturingListener listener = new CapturingListener<>(); + call.start(listener, new Metadata()); + + try { + assertThat( + checkCalled.await(5, TimeUnit.SECONDS)).isTrue(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + Context.CancellableContext authzContext = + call.getAuthzContextForTest(); + assertThat(authzContext.isCancelled()).isFalse(); + + call.cancel("client cancelled", null); + + assertThat(authzContext.isCancelled()).isTrue(); + } + + @Test + public void start_runsCheckUnderAuthzContext() { + Context[] capturedContext = new Context[1]; + doAnswer(invocation -> { + capturedContext[0] = Context.current(); + StreamObserver observer = + invocation.getArgument(1); + observer.onNext(CheckResponse.getDefaultInstance()); + observer.onCompleted(); + return null; + }).when(authzService) + .check(any(CheckRequest.class), + ArgumentMatchers.any()); + + HeaderMutations emptyMutations = HeaderMutations.create( + ImmutableList.of(), ImmutableList.of()); + AuthzResponse authzResponse = + AuthzResponse.allow(emptyMutations) + .setResponseHeaderMutations(emptyMutations) + .build(); + when(mockResponseHandler.handleResponse( + any(CheckResponse.class))).thenReturn(authzResponse); + + ExtAuthzClientCall call = + createCall(); + + CapturingListener listener = new CapturingListener<>(); + call.start(listener, new Metadata()); + + // The check was called under the authzContext + assertThat(capturedContext[0]).isNotNull(); + } + + @Test + public void start_triggersCheckRequest() { + doAnswer(invocation -> { + return null; + }).when(authzService) + .check(any(CheckRequest.class), + ArgumentMatchers.any()); + + ExtAuthzClientCall call = + createCall(); + + CapturingListener listener = new CapturingListener<>(); + call.start(listener, new Metadata()); + + verify(authzService) + .check(any(CheckRequest.class), + ArgumentMatchers.any()); + } + + @Test + public void authzCheckCompletes_contextIsCancelled() + throws InterruptedException { + CountDownLatch checkDone = new CountDownLatch(1); + doAnswer(invocation -> { + StreamObserver observer = + invocation.getArgument(1); + observer.onNext(CheckResponse.getDefaultInstance()); + observer.onCompleted(); + checkDone.countDown(); + return null; + }).when(authzService) + .check(any(CheckRequest.class), + ArgumentMatchers.any()); + + HeaderMutations emptyMutations = HeaderMutations.create( + ImmutableList.of(), ImmutableList.of()); + AuthzResponse authzResponse = + AuthzResponse.allow(emptyMutations) + .setResponseHeaderMutations(emptyMutations) + .build(); + when(mockResponseHandler.handleResponse( + any(CheckResponse.class))).thenReturn(authzResponse); + + ExtAuthzClientCall call = + createCall(); + + CapturingListener listener = new CapturingListener<>(); + call.start(listener, new Metadata()); + + assertThat(checkDone.await(5, TimeUnit.SECONDS)).isTrue(); + + // After observer.onNext the context should be cancelled + Context.CancellableContext authzContext = + call.getAuthzContextForTest(); + assertThat(authzContext.isCancelled()).isTrue(); + } + + @Test + public void authzCheckError_contextIsCancelled() + throws InterruptedException { + CountDownLatch checkDone = new CountDownLatch(1); + doAnswer(invocation -> { + StreamObserver observer = + invocation.getArgument(1); + observer.onError( + Status.UNAVAILABLE.asRuntimeException()); + checkDone.countDown(); + return null; + }).when(authzService) + .check(any(CheckRequest.class), + ArgumentMatchers.any()); + + ExtAuthzClientCall call = + createCall(); + + CapturingListener listener = + new CapturingListener<>(); + call.start(listener, new Metadata()); + + assertThat(checkDone.await(5, TimeUnit.SECONDS)).isTrue(); + + // After observer.onError the context should be cancelled + Context.CancellableContext authzContext = + call.getAuthzContextForTest(); + assertThat(authzContext.isCancelled()).isTrue(); + } + + @Test + public void authzCheckCompletes_allow_forwardsCall() throws Exception { + lastBackendHeaders = null; + + CountDownLatch checkDone = new CountDownLatch(1); + doAnswer(invocation -> { + StreamObserver observer = invocation.getArgument(1); + observer.onNext(CheckResponse.getDefaultInstance()); + observer.onCompleted(); + checkDone.countDown(); + return null; + }).when(authzService).check(any(CheckRequest.class), ArgumentMatchers.any()); + + HeaderMutations emptyMutations = HeaderMutations.create( + ImmutableList.of(), ImmutableList.of()); + AuthzResponse authzResponse = AuthzResponse.allow(emptyMutations) + .setResponseHeaderMutations(emptyMutations) + .build(); + when(mockResponseHandler.handleResponse(any(CheckResponse.class))).thenReturn(authzResponse); + + ExtAuthzClientCall call = createCall( + com.google.common.util.concurrent.MoreExecutors.directExecutor(), + channel, config); + CapturingListener listener = new CapturingListener<>(); + Metadata headers = new Metadata(); + call.start(listener, headers); + + assertThat(checkDone.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(lastBackendHeaders).isNotNull(); + verify(mockHeaderMutator, org.mockito.Mockito.never()).applyMutations(any(), any()); + } + + @Test + public void authzCheckCompletes_allowWithMutations_wrapsCall() throws Exception { + lastBackendHeaders = null; + + CountDownLatch checkDone = new CountDownLatch(1); + doAnswer(invocation -> { + StreamObserver observer = invocation.getArgument(1); + observer.onNext(CheckResponse.getDefaultInstance()); + observer.onCompleted(); + checkDone.countDown(); + return null; + }).when(authzService).check(any(CheckRequest.class), ArgumentMatchers.any()); + + HeaderMutations requestMutations = HeaderMutations.create( + ImmutableList.of(HeaderValueOption.create( + HeaderValue.create("foo", "bar"), + HeaderValueOption.HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD)), + ImmutableList.of()); + HeaderMutations responseMutations = HeaderMutations.create( + ImmutableList.of(), ImmutableList.of()); + AuthzResponse authzResponse = AuthzResponse.allow(requestMutations) + .setResponseHeaderMutations(responseMutations) + .build(); + when(mockResponseHandler.handleResponse(any(CheckResponse.class))).thenReturn(authzResponse); + + ExtAuthzClientCall call = createCall( + com.google.common.util.concurrent.MoreExecutors.directExecutor(), + channel, config); + CapturingListener listener = new CapturingListener<>(); + Metadata headers = new Metadata(); + call.start(listener, headers); + + assertThat(checkDone.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(lastBackendHeaders).isNotNull(); + verify(mockHeaderMutator).applyMutations(ArgumentMatchers.eq(requestMutations), any()); + } + + @Test + public void authzCheckCompletes_deny_failsCall() throws Exception { + CountDownLatch checkDone = new CountDownLatch(1); + doAnswer(invocation -> { + StreamObserver observer = invocation.getArgument(1); + observer.onNext(CheckResponse.getDefaultInstance()); + observer.onCompleted(); + checkDone.countDown(); + return null; + }).when(authzService).check(any(CheckRequest.class), ArgumentMatchers.any()); + + HeaderMutations emptyMutations = HeaderMutations.create( + ImmutableList.of(), ImmutableList.of()); + AuthzResponse authzResponse = AuthzResponse.deny(Status.PERMISSION_DENIED) + .setResponseHeaderMutations(emptyMutations) + .build(); + when(mockResponseHandler.handleResponse(any(CheckResponse.class))).thenReturn(authzResponse); + + ExtAuthzClientCall call = createCall( + com.google.common.util.concurrent.MoreExecutors.directExecutor(), channel, config); + CapturingListener listener = new CapturingListener<>(); + call.start(listener, new Metadata()); + + assertThat(checkDone.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(listener.getCloseStatus()).isEqualTo(Status.PERMISSION_DENIED); + verify(mockHeaderMutator, org.mockito.Mockito.never()).applyMutations(any(), any()); + } + + @Test + public void authzCheckCompletes_denyWithMutations_failsCallWithTrailers() throws Exception { + CountDownLatch checkDone = new CountDownLatch(1); + doAnswer(invocation -> { + StreamObserver observer = invocation.getArgument(1); + observer.onNext(CheckResponse.getDefaultInstance()); + observer.onCompleted(); + checkDone.countDown(); + return null; + }).when(authzService).check(any(CheckRequest.class), ArgumentMatchers.any()); + + HeaderMutations responseMutations = HeaderMutations.create( + ImmutableList.of(HeaderValueOption.create( + HeaderValue.create("foo", "bar"), + HeaderValueOption.HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD)), + ImmutableList.of()); + AuthzResponse authzResponse = AuthzResponse.deny(Status.PERMISSION_DENIED) + .setResponseHeaderMutations(responseMutations) + .build(); + when(mockResponseHandler.handleResponse(any(CheckResponse.class))).thenReturn(authzResponse); + + ExtAuthzClientCall call = createCall( + com.google.common.util.concurrent.MoreExecutors.directExecutor(), channel, config); + CapturingListener listener = new CapturingListener<>(); + call.start(listener, new Metadata()); + + assertThat(checkDone.await(5, TimeUnit.SECONDS)).isTrue(); + verify(mockHeaderMutator).applyMutations(ArgumentMatchers.eq(responseMutations), any()); + assertThat(listener.getCloseStatus()).isEqualTo(Status.PERMISSION_DENIED); + } + + @Test + public void authzCheckError_failClosed_failsCall() throws Exception { + ExtAuthzConfig failClosedConfig = ExtAuthzConfig.builder() + .grpcService(config.grpcService()) + .failureModeAllow(false) + .failureModeAllowHeaderAdd(false) + .includePeerCertificate(false) + .denyAtDisable(false) + .filterEnabled(Matchers.FractionMatcher.create(100, 100)) + .statusOnError(Status.PERMISSION_DENIED) + .build(); + + CountDownLatch checkDone = new CountDownLatch(1); + doAnswer(invocation -> { + StreamObserver observer = invocation.getArgument(1); + observer.onError(Status.UNAVAILABLE.asRuntimeException()); + checkDone.countDown(); + return null; + }).when(authzService).check(any(CheckRequest.class), ArgumentMatchers.any()); + + ExtAuthzClientCall call = createCall( + com.google.common.util.concurrent.MoreExecutors.directExecutor(), channel, + failClosedConfig); + CapturingListener listener = new CapturingListener<>(); + call.start(listener, new Metadata()); + + assertThat(checkDone.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(listener.getCloseStatus().getCode()).isEqualTo(Status.Code.PERMISSION_DENIED); + assertThat(listener.getCloseStatus().getCause()).isInstanceOf(StatusRuntimeException.class); + assertThat( + ((StatusRuntimeException) listener.getCloseStatus().getCause()) + .getStatus().getCode()) + .isEqualTo(Status.Code.UNAVAILABLE); + } + + @Test + public void authzCheckError_failOpen_forwardsCall() throws Exception { + ExtAuthzConfig failOpenConfig = ExtAuthzConfig.builder() + .grpcService(config.grpcService()) + .failureModeAllow(true) + .failureModeAllowHeaderAdd(false) + .includePeerCertificate(false) + .denyAtDisable(false) + .filterEnabled(Matchers.FractionMatcher.create(100, 100)) + .statusOnError(Status.PERMISSION_DENIED) + .build(); + + lastBackendHeaders = null; + + CountDownLatch checkDone = new CountDownLatch(1); + doAnswer(invocation -> { + StreamObserver observer = invocation.getArgument(1); + observer.onError(Status.UNAVAILABLE.asRuntimeException()); + checkDone.countDown(); + return null; + }).when(authzService).check(any(CheckRequest.class), ArgumentMatchers.any()); + + ExtAuthzClientCall call = createCall( + com.google.common.util.concurrent.MoreExecutors.directExecutor(), + channel, failOpenConfig); + CapturingListener listener = new CapturingListener<>(); + Metadata headers = new Metadata(); + call.start(listener, headers); + + assertThat(checkDone.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(lastBackendHeaders).isNotNull(); + verify(mockHeaderMutator, org.mockito.Mockito.never()).applyMutations(any(), any()); + } + + @Test + public void authzCheckError_failOpenWithHeaderAdd_wrapsCall() throws Exception { + ExtAuthzConfig failOpenWithHeaderConfig = ExtAuthzConfig.builder() + .grpcService(config.grpcService()) + .failureModeAllow(true) + .failureModeAllowHeaderAdd(true) + .includePeerCertificate(false) + .denyAtDisable(false) + .filterEnabled(Matchers.FractionMatcher.create(100, 100)) + .statusOnError(Status.PERMISSION_DENIED) + .build(); + + lastBackendHeaders = null; + + CountDownLatch checkDone = new CountDownLatch(1); + doAnswer(invocation -> { + StreamObserver observer = invocation.getArgument(1); + observer.onError(Status.UNAVAILABLE.asRuntimeException()); + checkDone.countDown(); + return null; + }).when(authzService).check(any(CheckRequest.class), ArgumentMatchers.any()); + + ExtAuthzClientCall call = createCall( + com.google.common.util.concurrent.MoreExecutors.directExecutor(), + channel, failOpenWithHeaderConfig); + CapturingListener listener = new CapturingListener<>(); + Metadata headers = new Metadata(); + call.start(listener, headers); + + assertThat(checkDone.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(lastBackendHeaders).isNotNull(); + } + + @Test + public void drain_executesOnCallExecutor() throws Exception { + java.util.concurrent.Executor mockExecutor = mock(java.util.concurrent.Executor.class); + doAnswer(invocation -> { + Runnable r = invocation.getArgument(0); + r.run(); + return null; + }).when(mockExecutor).execute(any(Runnable.class)); + + lastBackendHeaders = null; + + CountDownLatch checkDone = new CountDownLatch(1); + doAnswer(invocation -> { + StreamObserver observer = invocation.getArgument(1); + observer.onNext(CheckResponse.getDefaultInstance()); + observer.onCompleted(); + checkDone.countDown(); + return null; + }).when(authzService).check(any(CheckRequest.class), ArgumentMatchers.any()); + + HeaderMutations emptyMutations = HeaderMutations.create( + ImmutableList.of(), ImmutableList.of()); + AuthzResponse authzResponse = AuthzResponse.allow(emptyMutations) + .setResponseHeaderMutations(emptyMutations) + .build(); + when(mockResponseHandler.handleResponse(any(CheckResponse.class))).thenReturn(authzResponse); + + ExtAuthzClientCall call = + createCall(mockExecutor, channel, config); + CapturingListener listener = new CapturingListener<>(); + call.start(listener, new Metadata()); + + assertThat(checkDone.await(5, TimeUnit.SECONDS)).isTrue(); + verify(mockExecutor, org.mockito.Mockito.atLeastOnce()).execute(any(Runnable.class)); + } + + private ExtAuthzClientCall + createCall() { + return createCall(channel, config); + } + + private ExtAuthzClientCall + createCall(Channel next, ExtAuthzConfig config) { + return createCall(callOptions.getExecutor(), next, config); + } + + private ExtAuthzClientCall + createCall( + java.util.concurrent.Executor executor, Channel next, ExtAuthzConfig config) { + return new ExtAuthzClientCall<>( + executor, scheduler, callOptions, + next, + SimpleServiceGrpc.getUnaryRpcMethod(), + AuthorizationGrpc.newStub(channel), + mockCheckRequestBuilder, mockResponseHandler, + mockHeaderMutator, config); + } + + private static ExtAuthzConfig buildConfig() { + GrpcServiceConfig.GoogleGrpcConfig googleGrpc = + GrpcServiceConfig.GoogleGrpcConfig.builder() + .target("test-cluster") + .configuredChannelCredentials( + ConfiguredChannelCredentials.create( + mock(ChannelCredentials.class), + mock(ConfiguredChannelCredentials + .ChannelCredsConfig.class))) + .build(); + GrpcServiceConfig grpcServiceConfig = + GrpcServiceConfig.builder() + .googleGrpc(googleGrpc) + .initialMetadata(ImmutableList.of()) + .build(); + return ExtAuthzConfig.builder() + .grpcService(grpcServiceConfig) + .failureModeAllow(false) + .failureModeAllowHeaderAdd(false) + .includePeerCertificate(false) + .denyAtDisable(false) + .filterEnabled( + Matchers.FractionMatcher.create(100, 100)) + .statusOnError(Status.PERMISSION_DENIED) + .build(); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/extauthz/FailingCallWithTrailerMutationsTest.java b/xds/src/test/java/io/grpc/xds/internal/extauthz/FailingCallWithTrailerMutationsTest.java new file mode 100644 index 00000000000..0be19e5155c --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/extauthz/FailingCallWithTrailerMutationsTest.java @@ -0,0 +1,108 @@ +/* + * 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.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; + +import com.google.common.collect.ImmutableList; +import io.grpc.Metadata; +import io.grpc.Status; +import io.grpc.xds.internal.extauthz.ExtAuthzTestHelper.CapturingListener; +import io.grpc.xds.internal.headermutations.HeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderMutator; +import io.grpc.xds.internal.headermutations.HeaderValueOption; +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 FailingCallWithTrailerMutations}. + */ +@RunWith(JUnit4.class) +public class FailingCallWithTrailerMutationsTest { + + @Rule + public final MockitoRule mocks = MockitoJUnit.rule(); + + @Mock + private HeaderMutator mockHeaderMutator; + + private HeaderMutations responseMutations; + private Status failStatus; + private FailingCallWithTrailerMutations failingCall; + + @Before + public void setUp() { + HeaderValueOption option = HeaderValueOption.create( + io.grpc.xds.internal.grpcservice.HeaderValue.create("test-key", "test-value"), + HeaderValueOption.HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD); + responseMutations = HeaderMutations.create(ImmutableList.of(option), ImmutableList.of()); + failStatus = Status.PERMISSION_DENIED.withDescription("authz denied"); + + failingCall = new FailingCallWithTrailerMutations<>( + failStatus, + responseMutations, + mockHeaderMutator); + } + + @Test + public void start_appliesTrailersMutationsAndClosesListener() { + Metadata headers = new Metadata(); + CapturingListener listener = new CapturingListener<>(); + + // Trigger start on the failing call + failingCall.start(listener, headers); + + // 1. Verify that trailers are lazily allocated and mutations are applied to them + verify(mockHeaderMutator).applyMutations(eq(responseMutations), any(Metadata.class)); + + // 2. Verify that the listener was closed with the correct status + assertThat(listener.getCloseStatus()).isEqualTo(failStatus); + assertThat(listener.getCloseTrailers()).isNotNull(); + } + + @Test + public void outboundMethods_areNoOpsAndSafe() { + // Verify that calling any outbound transport methods does not throw any exceptions + failingCall.sendMessage(null); + failingCall.request(1); + failingCall.halfClose(); + failingCall.cancel("cancel", null); + } + + @Test + public void start_calledTwice_firesOnCloseTwice() { + CapturingListener firstListener = new CapturingListener<>(); + Metadata headers1 = new Metadata(); + failingCall.start(firstListener, headers1); + assertThat(firstListener.getCloseStatus()).isEqualTo(failStatus); + + // Call start again with a different listener + CapturingListener secondListener = new CapturingListener<>(); + Metadata headers2 = new Metadata(); + failingCall.start(secondListener, headers2); + assertThat(secondListener.getCloseStatus()).isEqualTo(failStatus); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/extauthz/FailingClientCallTest.java b/xds/src/test/java/io/grpc/xds/internal/extauthz/FailingClientCallTest.java new file mode 100644 index 00000000000..72439c3711c --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/extauthz/FailingClientCallTest.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.extauthz; + +import static com.google.common.truth.Truth.assertThat; + +import io.grpc.Metadata; +import io.grpc.NoopClientCall; +import io.grpc.Status; +import javax.annotation.Nullable; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for {@link FailingClientCall}. */ +@RunWith(JUnit4.class) +public class FailingClientCallTest { + + private final CapturingListener listener = new CapturingListener<>(); + + @Test + public void startCallsOnClose() { + Status error = Status.UNAVAILABLE.withDescription("test error"); + FailingClientCall call = new FailingClientCall<>(error); + Metadata metadata = new Metadata(); + call.start(listener, metadata); + + assertThat(listener.getCloseStatus()).isEqualTo(error); + assertThat(listener.getCloseTrailers()).isNotNull(); + assertThat(listener.getCloseTrailers().keys()).isEmpty(); + } + + @Test + public void otherMethodsAreNoOps() { + Status error = Status.UNAVAILABLE.withDescription("test error"); + FailingClientCall call = new FailingClientCall<>(error); + Metadata metadata = new Metadata(); + + call.start(listener, metadata); // Must call start first + + call.request(1); + call.cancel("message", new RuntimeException("cause")); + call.halfClose(); + call.sendMessage(new Object()); + + // Only one onClose should have been called (from start), no additional callbacks. + assertThat(listener.getCloseStatus()).isEqualTo(error); + assertThat(listener.getCloseTrailers()).isNotNull(); + assertThat(listener.getCloseTrailers().keys()).isEmpty(); + } + + /** A capturing listener that records the status and trailers from {@link #onClose}. */ + private static final class CapturingListener + extends NoopClientCall.NoopClientCallListener { + @Nullable private Status closeStatus; + @Nullable private Metadata closeTrailers; + + @Override + public void onClose(Status status, Metadata trailers) { + this.closeStatus = status; + this.closeTrailers = trailers; + } + + @Nullable + Status getCloseStatus() { + return closeStatus; + } + + @Nullable + Metadata getCloseTrailers() { + return closeTrailers; + } + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/extauthz/MutatingClientCallTest.java b/xds/src/test/java/io/grpc/xds/internal/extauthz/MutatingClientCallTest.java new file mode 100644 index 00000000000..45cfbb5bfae --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/extauthz/MutatingClientCallTest.java @@ -0,0 +1,186 @@ +/* + * 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.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; + +import com.google.common.collect.ImmutableList; +import io.grpc.Metadata; +import io.grpc.Status; +import io.grpc.xds.internal.extauthz.ExtAuthzTestHelper.CapturingClientCall; +import io.grpc.xds.internal.extauthz.ExtAuthzTestHelper.CapturingListener; +import io.grpc.xds.internal.headermutations.HeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderMutator; +import io.grpc.xds.internal.headermutations.HeaderValueOption; +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 MutatingClientCall}. + */ +@RunWith(JUnit4.class) +public class MutatingClientCallTest { + + @Rule + public final MockitoRule mocks = MockitoJUnit.rule(); + + private final CapturingClientCall mockDelegateCall = new CapturingClientCall<>(); + @Mock + private HeaderMutator mockHeaderMutator; + private final CapturingListener mockResponseListener = new CapturingListener<>(); + + private HeaderMutations requestMutations; + private HeaderMutations responseMutations; + private MutatingClientCall mutatingCall; + + @Before + public void setUp() { + HeaderValueOption option = HeaderValueOption.create( + io.grpc.xds.internal.grpcservice.HeaderValue.create("test-key", "test-value"), + HeaderValueOption.HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD); + requestMutations = HeaderMutations.create(ImmutableList.of(option), ImmutableList.of()); + responseMutations = HeaderMutations.create(ImmutableList.of(option), ImmutableList.of()); + + mutatingCall = new MutatingClientCall<>( + mockDelegateCall, + requestMutations, + responseMutations, + mockHeaderMutator); + } + + @Test + public void start_appliesRequestMutationsSymmetrically() { + Metadata headers = new Metadata(); + + // Trigger start + mutatingCall.start(mockResponseListener, headers); + + // 1. Verify request mutations are applied lazily to headers before forwarding start + verify(mockHeaderMutator).applyMutations(eq(requestMutations), eq(headers)); + + // 2. Verify delegate start was triggered with the mutated headers and captured listener + assertThat(mockDelegateCall.isStarted()).isTrue(); + assertThat(mockDelegateCall.getHeaders()).isSameInstanceAs(headers); + assertThat(mockDelegateCall.getListener()).isNotNull(); + } + + @Test + public void onHeaders_appliesResponseMutationsSymmetrically() { + Metadata headers = new Metadata(); + mutatingCall.start(mockResponseListener, headers); + + // Get the wrapped listener that was passed to delegate + assertThat(mockDelegateCall.getListener()).isNotNull(); + + // Trigger onHeaders on captured listener + Metadata responseHeaders = new Metadata(); + mockDelegateCall.getListener().onHeaders(responseHeaders); + + // 1. Verify response mutations are applied lazily to response headers + verify(mockHeaderMutator).applyMutations(eq(responseMutations), eq(responseHeaders)); + + // 2. Verify response headers were forwarded to real listener + assertThat(mockResponseListener.getHeaders()).isSameInstanceAs(responseHeaders); + } + + @Test + public void listenerCallbacks_areDelegatedCorrectly() { + Metadata headers = new Metadata(); + mutatingCall.start(mockResponseListener, headers); + + assertThat(mockDelegateCall.getListener()).isNotNull(); + + // Test onMessage delegation + mockDelegateCall.getListener().onMessage(null); + assertThat(mockResponseListener.getMessages()).containsExactly((Void) null); + + // Test onReady delegation + mockDelegateCall.getListener().onReady(); + assertThat(mockResponseListener.isOnReadyCalled()).isTrue(); + + // Test onClose delegation + Status expectedStatus = Status.OK; + Metadata expectedTrailers = new Metadata(); + mockDelegateCall.getListener().onClose(expectedStatus, expectedTrailers); + assertThat(mockResponseListener.getCloseStatus()).isEqualTo(expectedStatus); + assertThat(mockResponseListener.getCloseTrailers()).isSameInstanceAs(expectedTrailers); + } + + @Test + public void emptyRequestMutations_nonEmptyResponseMutations_wrapsListener() { + HeaderMutations emptyRequest = + HeaderMutations.create(ImmutableList.of(), ImmutableList.of()); + CapturingClientCall delegate = new CapturingClientCall<>(); + MutatingClientCall call = new MutatingClientCall<>( + delegate, emptyRequest, responseMutations, mockHeaderMutator); + + Metadata headers = new Metadata(); + CapturingListener listener = new CapturingListener<>(); + call.start(listener, headers); + + // Request mutations should still be called (even if empty) + verify(mockHeaderMutator).applyMutations(eq(emptyRequest), eq(headers)); + + // Verify delegate was started with a wrapped listener + assertThat(delegate.isStarted()).isTrue(); + assertThat(delegate.getHeaders()).isSameInstanceAs(headers); + assertThat(delegate.getListener()).isNotNull(); + + // Trigger onHeaders and verify response mutations are applied + Metadata responseHeaders = new Metadata(); + delegate.getListener().onHeaders(responseHeaders); + verify(mockHeaderMutator).applyMutations( + eq(responseMutations), eq(responseHeaders)); + assertThat(listener.getHeaders()).isSameInstanceAs(responseHeaders); + } + + @Test + public void start_emptyResponseMutations_headersPassThroughUnmodified() { + HeaderMutations emptyResponseMutations = + HeaderMutations.create(ImmutableList.of(), ImmutableList.of()); + CapturingClientCall delegate = new CapturingClientCall<>(); + MutatingClientCall call = new MutatingClientCall<>( + delegate, requestMutations, emptyResponseMutations, mockHeaderMutator); + + CapturingListener listener = new CapturingListener<>(); + Metadata headers = new Metadata(); + call.start(listener, headers); + + // Request mutations should be applied + verify(mockHeaderMutator).applyMutations(eq(requestMutations), eq(headers)); + + // Simulate receiving response headers from the server + Metadata.Key responseKey = + Metadata.Key.of("x-response-header", Metadata.ASCII_STRING_MARSHALLER); + Metadata responseHeaders = new Metadata(); + responseHeaders.put(responseKey, "original-value"); + delegate.getListener().onHeaders(responseHeaders); + + // Verify: headers pass through UNMODIFIED — no response mutation applied + assertThat(listener.getHeaders().get(responseKey)).isEqualTo("original-value"); + // headerMutator should NOT have been called again for response mutations + org.mockito.Mockito.verifyNoMoreInteractions(mockHeaderMutator); + } +}