diff --git a/xds/src/main/java/io/grpc/xds/internal/extauthz/AuthzResponse.java b/xds/src/main/java/io/grpc/xds/internal/extauthz/AuthzResponse.java new file mode 100644 index 00000000000..65ed8db48f9 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extauthz/AuthzResponse.java @@ -0,0 +1,97 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.extauthz; + +import com.google.auto.value.AutoValue; +import com.google.common.collect.ImmutableList; +import io.grpc.Status; +import io.grpc.xds.internal.headermutations.HeaderMutations; +import java.util.Optional; + +/** + * Represents the outcome of an authorization check, detailing whether the request is allowed or + * denied and including any associated headers or status information. + */ +@AutoValue +abstract class AuthzResponse { + + /** Defines the authorization decision. */ + public enum Decision { + /** The request is permitted. */ + ALLOW, + /** The request is rejected. */ + DENY, + } + + private static final HeaderMutations EMPTY_MUTATIONS = + HeaderMutations.create(ImmutableList.of(), ImmutableList.of()); + + /** + * Creates a builder for an ALLOW response, initializing with the specified request header + * mutations. + */ + static Builder allow(HeaderMutations requestHeaderMutations) { + return new AutoValue_AuthzResponse.Builder().setDecision(Decision.ALLOW) + .setResponseHeaderMutations(EMPTY_MUTATIONS) + .setRequestHeaderMutations(requestHeaderMutations); + } + + /** Creates a builder for a DENY response, initializing with the specified status. */ + static Builder deny(Status status) { + return new AutoValue_AuthzResponse.Builder().setDecision(Decision.DENY) + .setResponseHeaderMutations(EMPTY_MUTATIONS) + .setRequestHeaderMutations(EMPTY_MUTATIONS) + .setStatus(status); + } + + /** Returns the authorization decision. */ + public abstract Decision decision(); + + /** + * For DENY decisions, this provides the status to be returned to the calling client. It is empty + * for ALLOW decisions. + */ + public abstract Optional status(); + + /** + * Returns mutations to be applied to the request headers. This is used for ALLOW decisions. + */ + public abstract HeaderMutations requestHeaderMutations(); + + /** + * Returns mutations to be applied to the response headers. This is used for both ALLOW and DENY + * decisions. + */ + public abstract HeaderMutations responseHeaderMutations(); + + /** Builder for creating {@link AuthzResponse} instances. */ + @AutoValue.Builder + abstract static class Builder { + + abstract Builder setDecision(Decision decision); + + abstract Builder setStatus(Status status); + + public abstract Builder setRequestHeaderMutations( + HeaderMutations requestHeaderMutations); + + public abstract Builder setResponseHeaderMutations( + HeaderMutations responseHeaderMutations); + + public abstract AuthzResponse build(); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/extauthz/CheckResponseHandler.java b/xds/src/main/java/io/grpc/xds/internal/extauthz/CheckResponseHandler.java new file mode 100644 index 00000000000..910def92583 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extauthz/CheckResponseHandler.java @@ -0,0 +1,160 @@ +/* + * 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.envoyproxy.envoy.service.auth.v3.DeniedHttpResponse; +import io.envoyproxy.envoy.service.auth.v3.OkHttpResponse; +import io.grpc.Metadata; +import io.grpc.Status; +import io.grpc.internal.GrpcUtil; +import io.grpc.xds.internal.grpcservice.HeaderValue; +import io.grpc.xds.internal.grpcservice.HeaderValueValidationUtils; +import io.grpc.xds.internal.headermutations.HeaderMutationDisallowedException; +import io.grpc.xds.internal.headermutations.HeaderMutationFilter; +import io.grpc.xds.internal.headermutations.HeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderValueOption; +import javax.annotation.concurrent.ThreadSafe; + +/** + * Handles the response from the external authorization service, processing it to determine the + * authorization decision and applying any necessary header mutations. + */ +@ThreadSafe +public class CheckResponseHandler { + private final HeaderMutationFilter headerMutationFilter; + + public CheckResponseHandler(HeaderMutationFilter headerMutationFilter) { + this.headerMutationFilter = headerMutationFilter; + } + + AuthzResponse handleResponse(final CheckResponse response) { + try { + if (response.getStatus().getCode() == Status.Code.OK.value()) { + return handleOkResponse(response); + } else { + return handleNotOkResponse(response); + } + } catch (HeaderMutationDisallowedException e) { + return AuthzResponse.deny(e.getStatus()).build(); + } + } + + private AuthzResponse handleOkResponse(final CheckResponse response) + throws HeaderMutationDisallowedException { + if (!response.hasOkResponse()) { + return AuthzResponse.allow( + HeaderMutations.create(ImmutableList.of(), ImmutableList.of())).build(); + } + OkHttpResponse okResponse = response.getOkResponse(); + CheckResponseMutations allowedMutations = buildHeaderMutationsFromOkResponse(okResponse); + + return AuthzResponse.allow(allowedMutations.requestMutations()) + .setResponseHeaderMutations(allowedMutations.responseMutations()).build(); + } + + private CheckResponseMutations buildHeaderMutationsFromOkResponse(OkHttpResponse okResponse) + throws HeaderMutationDisallowedException { + HeaderMutations requestMutations = HeaderMutations.create( + convertHeaders(okResponse.getHeadersList()), + ImmutableList.copyOf(okResponse.getHeadersToRemoveList())); + HeaderMutations responseMutations = HeaderMutations.create( + convertHeaders(okResponse.getResponseHeadersToAddList()), + ImmutableList.of()); + return CheckResponseMutations.create( + headerMutationFilter.filter(requestMutations), + headerMutationFilter.filter(responseMutations)); + } + + private AuthzResponse handleNotOkResponse(CheckResponse response) + throws HeaderMutationDisallowedException { + String baseMsg = "RPC denied by external authorization server"; + String outerMsg = response.getStatus().getMessage(); + String description = outerMsg.isEmpty() ? baseMsg : baseMsg + ": " + outerMsg; + + if (!response.hasDeniedResponse()) { + return AuthzResponse.deny(Status.PERMISSION_DENIED.withDescription(description)).build(); + } + DeniedHttpResponse deniedResponse = response.getDeniedResponse(); + CheckResponseMutations allowedMutations = + buildHeaderMutationsFromDeniedResponse(deniedResponse); + + Status status = Status.PERMISSION_DENIED; + if (deniedResponse.hasStatus()) { + status = GrpcUtil.httpStatusToGrpcStatus(deniedResponse.getStatus().getCodeValue()); + } + // Per gRFC A92: deniedResponse.body is ignored for gRPC (doesn't apply to gRPC). + return AuthzResponse.deny(status.withDescription(description)) + .setResponseHeaderMutations(allowedMutations.responseMutations()).build(); + } + + private CheckResponseMutations buildHeaderMutationsFromDeniedResponse( + DeniedHttpResponse deniedResponse) throws HeaderMutationDisallowedException { + HeaderMutations requestMutations = + HeaderMutations.create(ImmutableList.of(), ImmutableList.of()); + HeaderMutations responseMutations = HeaderMutations.create( + convertHeaders(deniedResponse.getHeadersList()), + ImmutableList.of()); + return CheckResponseMutations.create( + headerMutationFilter.filter(requestMutations), + headerMutationFilter.filter(responseMutations)); + } + + private ImmutableList convertHeaders( + java.util.List headersList) + throws HeaderMutationDisallowedException { + ImmutableList.Builder builder = ImmutableList.builder(); + for (io.envoyproxy.envoy.config.core.v3.HeaderValueOption optionProto : headersList) { + io.envoyproxy.envoy.config.core.v3.HeaderValue header = optionProto.getHeader(); + String key = header.getKey(); + HeaderValue internalHeader; + if (key.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + internalHeader = HeaderValue.create(key, header.getRawValue()); + } else { + internalHeader = HeaderValue.create(key, header.getValue()); + } + if (HeaderValueValidationUtils.isDisallowed(internalHeader)) { + continue; + } + HeaderValueOption.HeaderAppendAction action; + switch (optionProto.getAppendAction()) { + case APPEND_IF_EXISTS_OR_ADD: + action = HeaderValueOption.HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD; + break; + case ADD_IF_ABSENT: + action = HeaderValueOption.HeaderAppendAction.ADD_IF_ABSENT; + break; + case OVERWRITE_IF_EXISTS_OR_ADD: + action = HeaderValueOption.HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD; + break; + case OVERWRITE_IF_EXISTS: + action = HeaderValueOption.HeaderAppendAction.OVERWRITE_IF_EXISTS; + break; + case UNRECOGNIZED: + default: + // Envoy Parity / Spec Parity: Unconditionally reject invalid/unrecognized append actions + throw new HeaderMutationDisallowedException( + "Unrecognized HeaderAppendAction: " + optionProto.getAppendAction()); + } + builder + .add(HeaderValueOption.create(internalHeader, action)); + } + return builder.build(); + } +} + diff --git a/xds/src/main/java/io/grpc/xds/internal/extauthz/CheckResponseMutations.java b/xds/src/main/java/io/grpc/xds/internal/extauthz/CheckResponseMutations.java new file mode 100644 index 00000000000..46908712c5e --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extauthz/CheckResponseMutations.java @@ -0,0 +1,37 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.extauthz; + +import com.google.auto.value.AutoValue; +import io.grpc.xds.internal.headermutations.HeaderMutations; + +/** + * A collection of header mutations for an external authorization response. + * It contains separate mutations for request headers and response headers. + */ +@AutoValue +abstract class CheckResponseMutations { + + static CheckResponseMutations create(HeaderMutations requestMutations, + HeaderMutations responseMutations) { + return new AutoValue_CheckResponseMutations(requestMutations, responseMutations); + } + + public abstract HeaderMutations requestMutations(); + + public abstract HeaderMutations responseMutations(); +} diff --git a/xds/src/test/java/io/grpc/xds/internal/extauthz/AuthzResponseTest.java b/xds/src/test/java/io/grpc/xds/internal/extauthz/AuthzResponseTest.java new file mode 100644 index 00000000000..39aa3e33277 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/extauthz/AuthzResponseTest.java @@ -0,0 +1,91 @@ +/* + * 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 com.google.common.collect.ImmutableList; +import io.grpc.Status; +import io.grpc.xds.internal.extauthz.AuthzResponse.Decision; +import io.grpc.xds.internal.headermutations.HeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderValueOption; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class AuthzResponseTest { + @Test + public void testAllow() { + HeaderMutations requestMutations = + HeaderMutations.create(ImmutableList.of(), ImmutableList.of()); + AuthzResponse response = AuthzResponse.allow(requestMutations).build(); + assertThat(response.decision()).isEqualTo(Decision.ALLOW); + assertThat(response.requestHeaderMutations()).isEqualTo(requestMutations); + assertThat(response.status()).isEmpty(); + assertThat(response.responseHeaderMutations().headers()).isEmpty(); + } + + @Test + public void testAllowWithHeaderMutations() { + HeaderMutations requestMutations = + HeaderMutations.create(ImmutableList.of(), ImmutableList.of()); + HeaderMutations responseMutations = + HeaderMutations.create( + ImmutableList.of( + HeaderValueOption.create( + io.grpc.xds.internal.grpcservice.HeaderValue.create("key", "value"), + HeaderValueOption.HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD)), + ImmutableList.of()); + AuthzResponse response = + AuthzResponse.allow(requestMutations) + .setResponseHeaderMutations(responseMutations) + .build(); + assertThat(response.decision()).isEqualTo(Decision.ALLOW); + assertThat(response.requestHeaderMutations()).isEqualTo(requestMutations); + assertThat(response.responseHeaderMutations()).isEqualTo(responseMutations); + } + + @Test + public void testDeny() { + Status status = Status.PERMISSION_DENIED.withDescription("reason"); + AuthzResponse response = AuthzResponse.deny(status).build(); + assertThat(response.decision()).isEqualTo(Decision.DENY); + assertThat(response.status()).hasValue(status); + assertThat(response.requestHeaderMutations().headers()).isEmpty(); + assertThat(response.responseHeaderMutations().headers()).isEmpty(); + } + + @Test + public void testDenyWithResponseMutations() { + Status status = Status.PERMISSION_DENIED.withDescription("reason"); + HeaderMutations responseMutations = + HeaderMutations.create( + ImmutableList.of( + HeaderValueOption.create( + io.grpc.xds.internal.grpcservice.HeaderValue.create("x-deny-info", "blocked"), + HeaderValueOption.HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD)), + ImmutableList.of()); + AuthzResponse response = AuthzResponse.deny(status) + .setResponseHeaderMutations(responseMutations) + .build(); + assertThat(response.decision()).isEqualTo(Decision.DENY); + assertThat(response.status()).hasValue(status); + assertThat(response.responseHeaderMutations()).isEqualTo(responseMutations); + assertThat(response.requestHeaderMutations().headers()).isEmpty(); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/extauthz/CheckResponseHandlerTest.java b/xds/src/test/java/io/grpc/xds/internal/extauthz/CheckResponseHandlerTest.java new file mode 100644 index 00000000000..1ba4d21f9d2 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/extauthz/CheckResponseHandlerTest.java @@ -0,0 +1,306 @@ +/* + * 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.when; + +import com.google.common.collect.ImmutableList; +import com.google.rpc.Code; +import io.envoyproxy.envoy.config.core.v3.HeaderValue; +import io.envoyproxy.envoy.config.core.v3.HeaderValueOption; +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.grpc.Status; +import io.grpc.xds.internal.extauthz.AuthzResponse.Decision; +import io.grpc.xds.internal.headermutations.HeaderMutationDisallowedException; +import io.grpc.xds.internal.headermutations.HeaderMutationFilter; +import io.grpc.xds.internal.headermutations.HeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderValueOption.HeaderAppendAction; +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 CheckResponseHandler}. + */ +@RunWith(JUnit4.class) +public class CheckResponseHandlerTest { + + @Rule + public final MockitoRule mockitoRule = MockitoJUnit.rule(); + + @Mock + private HeaderMutationFilter headerMutationFilter; + + private CheckResponseHandler responseHandler; + + @Before + public void setUp() throws Exception { + responseHandler = new CheckResponseHandler(headerMutationFilter); + when(headerMutationFilter.filter(any(HeaderMutations.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + } + + @Test + public void handleResponse_ok() { + CheckResponse checkResponse = CheckResponse.newBuilder() + .setStatus(com.google.rpc.Status.newBuilder().setCode(Code.OK_VALUE).build()).build(); + AuthzResponse authzResponse = responseHandler.handleResponse(checkResponse); + assertThat(authzResponse.decision()).isEqualTo(Decision.ALLOW); + assertThat(authzResponse.requestHeaderMutations()) + .isEqualTo(HeaderMutations.create(ImmutableList.of(), ImmutableList.of())); + } + + @Test + public void handleResponse_okWithMutations() { + HeaderValueOption option = + HeaderValueOption.newBuilder().setHeader(HeaderValue + .newBuilder().setKey("test-key").setValue("test-value")).build(); + io.grpc.xds.internal.headermutations.HeaderValueOption expectedOption = + io.grpc.xds.internal.headermutations.HeaderValueOption.create( + io.grpc.xds.internal.grpcservice.HeaderValue.create("test-key", "test-value"), + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD); + CheckResponse checkResponse = CheckResponse.newBuilder() + .setStatus(com.google.rpc.Status.newBuilder().setCode(Code.OK_VALUE).build()) + .setOkResponse(OkHttpResponse.newBuilder().addHeaders(option) + .addHeadersToRemove("remove-key").addResponseHeadersToAdd(option).build()) + .build(); + AuthzResponse authzResponse = responseHandler.handleResponse(checkResponse); + assertThat(authzResponse.decision()).isEqualTo(Decision.ALLOW); + HeaderMutations expectedRequestMutations = HeaderMutations.create( + ImmutableList.of(expectedOption), ImmutableList.of("remove-key")); + HeaderMutations expectedResponseMutations = HeaderMutations.create( + ImmutableList.of(expectedOption), ImmutableList.of()); + assertThat(authzResponse.requestHeaderMutations()).isEqualTo(expectedRequestMutations); + assertThat(authzResponse.responseHeaderMutations()) + .isEqualTo(expectedResponseMutations); + } + + @Test + public void handleResponse_notOk() { + CheckResponse checkResponse = CheckResponse.newBuilder().setStatus(com.google.rpc.Status + .newBuilder().setCode(Code.PERMISSION_DENIED_VALUE).setMessage("denied").build()).build(); + AuthzResponse authzResponse = responseHandler.handleResponse(checkResponse); + assertThat(authzResponse.decision()).isEqualTo(Decision.DENY); + assertThat(authzResponse.status().isPresent()).isTrue(); + assertThat(authzResponse.status().get().getCode()) + .isEqualTo(Status.PERMISSION_DENIED.getCode()); + assertThat(authzResponse.status().get().getDescription()) + .isEqualTo("RPC denied by external authorization server: denied"); + } + + @Test + public void handleResponse_deniedResponseWithoutStatusOverride() { + HeaderValueOption option = + HeaderValueOption.newBuilder().setHeader(HeaderValue + .newBuilder().setKey("test-key").setValue("test-value")).build(); + io.grpc.xds.internal.headermutations.HeaderValueOption expectedOption = + io.grpc.xds.internal.headermutations.HeaderValueOption.create( + io.grpc.xds.internal.grpcservice.HeaderValue.create("test-key", "test-value"), + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD); + DeniedHttpResponse deniedHttpResponse = + DeniedHttpResponse.newBuilder().addHeaders(option).build(); + CheckResponse checkResponse = CheckResponse.newBuilder() + .setStatus(com.google.rpc.Status.newBuilder().setCode(Code.ABORTED_VALUE).build()) + .setDeniedResponse(deniedHttpResponse).build(); + AuthzResponse authzResponse = responseHandler.handleResponse(checkResponse); + assertThat(authzResponse.decision()).isEqualTo(Decision.DENY); + assertThat(authzResponse.status().get().getCode()) + .isEqualTo(Status.PERMISSION_DENIED.getCode()); + assertThat(authzResponse.status().get().getDescription()) + .isEqualTo("RPC denied by external authorization server"); + HeaderMutations expectedMutations = + HeaderMutations.create(ImmutableList.of(expectedOption), ImmutableList.of()); + assertThat(authzResponse.responseHeaderMutations()).isEqualTo(expectedMutations); + } + + @Test + public void handleResponse_deniedResponseWithStatusOverride() { + DeniedHttpResponse deniedHttpResponse = + DeniedHttpResponse.newBuilder().setStatus(HttpStatus.newBuilder().setCodeValue(401).build()) + .setBody("custom body").build(); + CheckResponse checkResponse = CheckResponse.newBuilder() + .setStatus(com.google.rpc.Status.newBuilder().setCode(Code.ABORTED_VALUE).build()) + .setDeniedResponse(deniedHttpResponse).build(); + AuthzResponse authzResponse = responseHandler.handleResponse(checkResponse); + assertThat(authzResponse.decision()).isEqualTo(Decision.DENY); + assertThat(authzResponse.status().isPresent()).isTrue(); + Status status = authzResponse.status().get(); + assertThat(status.getCode()).isEqualTo(Status.Code.UNAUTHENTICATED); + // Per gRFC A92: body is ignored for gRPC, so description comes from status message + assertThat(status.getDescription()) + .isEqualTo("RPC denied by external authorization server"); + HeaderMutations expectedMutations = + HeaderMutations.create(ImmutableList.of(), ImmutableList.of()); + assertThat(authzResponse.responseHeaderMutations()).isEqualTo(expectedMutations); + } + + @Test + public void handleResponse_okWithDisallowedMutation() throws HeaderMutationDisallowedException { + CheckResponse checkResponse = CheckResponse.newBuilder() + .setStatus(com.google.rpc.Status.newBuilder().setCode(Code.OK_VALUE).build()) + .setOkResponse(OkHttpResponse.newBuilder().build()).build(); + HeaderMutationDisallowedException exception = + new HeaderMutationDisallowedException("disallowed"); + when(headerMutationFilter.filter(any(HeaderMutations.class))).thenThrow(exception); + + AuthzResponse authzResponse = responseHandler.handleResponse(checkResponse); + + assertThat(authzResponse.decision()).isEqualTo(Decision.DENY); + assertThat(authzResponse.status().get().getCode()).isEqualTo(Status.INTERNAL.getCode()); + assertThat(authzResponse.status().get().getDescription()).isEqualTo("disallowed"); + } + + @Test + public void handleResponse_ok_edgeCaseHeaders() { + HeaderValueOption binaryOption = + HeaderValueOption.newBuilder().setHeader(HeaderValue.newBuilder().setKey("test-bin") + .setRawValue(com.google.protobuf.ByteString.copyFromUtf8("test"))).build(); + HeaderValueOption disallowedOption = HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey("host").setValue("disallowed")).build(); + + io.grpc.xds.internal.headermutations.HeaderValueOption expectedBinaryOption = + io.grpc.xds.internal.headermutations.HeaderValueOption.create( + io.grpc.xds.internal.grpcservice.HeaderValue.create("test-bin", + com.google.protobuf.ByteString.copyFromUtf8("test")), + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD); + + CheckResponse checkResponse = CheckResponse.newBuilder() + .setStatus(com.google.rpc.Status.newBuilder().setCode(Code.OK_VALUE).build()) + .setOkResponse(OkHttpResponse.newBuilder().addHeaders(binaryOption) + .addHeaders(disallowedOption).build()) + .build(); + AuthzResponse authzResponse = responseHandler.handleResponse(checkResponse); + + assertThat(authzResponse.decision()).isEqualTo(Decision.ALLOW); + HeaderMutations expectedRequestMutations = HeaderMutations + .create(ImmutableList.of(expectedBinaryOption), ImmutableList.of()); + + assertThat(authzResponse.requestHeaderMutations()).isEqualTo(expectedRequestMutations); + } + + @Test + public void handleResponse_ok_invalidAppendAction_deniesCall() { + HeaderValueOption invalidActionOption = HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey("test-unknown-action").setValue("test-value")) + .setAppendActionValue(999).build(); + + CheckResponse checkResponse = CheckResponse.newBuilder() + .setStatus(com.google.rpc.Status.newBuilder().setCode(Code.OK_VALUE).build()) + .setOkResponse(OkHttpResponse.newBuilder().addHeaders(invalidActionOption).build()) + .build(); + AuthzResponse authzResponse = responseHandler.handleResponse(checkResponse); + + assertThat(authzResponse.decision()).isEqualTo(Decision.DENY); + assertThat(authzResponse.status().get().getCode()).isEqualTo(Status.INTERNAL.getCode()); + assertThat(authzResponse.status().get().getDescription()) + .contains("Unrecognized HeaderAppendAction: UNRECOGNIZED"); + } + + @Test + public void handleResponse_deniedResponseBodyIgnored() { + DeniedHttpResponse deniedHttpResponse = + DeniedHttpResponse.newBuilder().setStatus(HttpStatus.newBuilder().setCodeValue(403).build()) + .setBody("custom body text").build(); + CheckResponse checkResponse = CheckResponse.newBuilder() + .setStatus(com.google.rpc.Status.newBuilder().setCode(Code.PERMISSION_DENIED_VALUE).build()) + .setDeniedResponse(deniedHttpResponse).build(); + + AuthzResponse authzResponse = responseHandler.handleResponse(checkResponse); + + assertThat(authzResponse.decision()).isEqualTo(Decision.DENY); + assertThat(authzResponse.status().isPresent()).isTrue(); + Status status = authzResponse.status().get(); + assertThat(status.getCode()).isEqualTo(Status.Code.PERMISSION_DENIED); + // Per gRFC A92: body is ignored for gRPC + assertThat(status.getDescription()) + .isEqualTo("RPC denied by external authorization server"); + } + + @Test + public void handleResponse_ok_overwriteIfExistsAction() { + HeaderValueOption overwriteOption = HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey("x-custom").setValue("val")) + .setAppendAction( + io.envoyproxy.envoy.config.core.v3.HeaderValueOption + .HeaderAppendAction.OVERWRITE_IF_EXISTS) + .build(); + + CheckResponse checkResponse = CheckResponse.newBuilder() + .setStatus(com.google.rpc.Status.newBuilder().setCode(Code.OK_VALUE).build()) + .setOkResponse(OkHttpResponse.newBuilder().addHeaders(overwriteOption).build()) + .build(); + AuthzResponse authzResponse = responseHandler.handleResponse(checkResponse); + + assertThat(authzResponse.decision()).isEqualTo(Decision.ALLOW); + assertThat(authzResponse.requestHeaderMutations().headers()).hasSize(1); + assertThat(authzResponse.requestHeaderMutations().headers().get(0).appendAction()) + .isEqualTo(HeaderAppendAction.OVERWRITE_IF_EXISTS); + } + + @Test + public void handleResponse_ok_addIfAbsentAction() { + HeaderValueOption addIfAbsentOption = HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey("x-custom").setValue("val")) + .setAppendAction( + io.envoyproxy.envoy.config.core.v3.HeaderValueOption + .HeaderAppendAction.ADD_IF_ABSENT) + .build(); + + CheckResponse checkResponse = CheckResponse.newBuilder() + .setStatus(com.google.rpc.Status.newBuilder().setCode(Code.OK_VALUE).build()) + .setOkResponse(OkHttpResponse.newBuilder().addHeaders(addIfAbsentOption).build()) + .build(); + AuthzResponse authzResponse = responseHandler.handleResponse(checkResponse); + + assertThat(authzResponse.decision()).isEqualTo(Decision.ALLOW); + assertThat(authzResponse.requestHeaderMutations().headers()).hasSize(1); + assertThat(authzResponse.requestHeaderMutations().headers().get(0).appendAction()) + .isEqualTo(HeaderAppendAction.ADD_IF_ABSENT); + } + + @Test + public void handleResponse_ok_overwriteIfExistsOrAddAction() { + HeaderValueOption overwriteOrAddOption = HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey("x-custom").setValue("val")) + .setAppendAction( + io.envoyproxy.envoy.config.core.v3.HeaderValueOption + .HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD) + .build(); + + CheckResponse checkResponse = CheckResponse.newBuilder() + .setStatus(com.google.rpc.Status.newBuilder().setCode(Code.OK_VALUE).build()) + .setOkResponse(OkHttpResponse.newBuilder().addHeaders(overwriteOrAddOption).build()) + .build(); + AuthzResponse authzResponse = responseHandler.handleResponse(checkResponse); + + assertThat(authzResponse.decision()).isEqualTo(Decision.ALLOW); + assertThat(authzResponse.requestHeaderMutations().headers()).hasSize(1); + assertThat(authzResponse.requestHeaderMutations().headers().get(0).appendAction()) + .isEqualTo(HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD); + } + +}