-
Notifications
You must be signed in to change notification settings - Fork 4k
xds: Add ext_authz response handling #12896
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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> 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(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: Can just inline the expression without using a local variable. |
||
| CheckResponseMutations allowedMutations = buildHeaderMutationsFromOkResponse(okResponse); | ||
|
|
||
| return AuthzResponse.allow(allowedMutations.requestMutations()) | ||
| .setResponseHeaderMutations(allowedMutations.responseMutations()).build(); | ||
|
Comment on lines
+67
to
+68
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looking at this usage, the response mutations can also be an argument to |
||
| } | ||
|
|
||
| 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since only response mutations is used for the denied case, can we either avoid calling this method that creates mutations for both request and response, or add a boolean argument to indicate skipping request mutations? |
||
|
|
||
| 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<HeaderValueOption> convertHeaders( | ||
| java.util.List<io.envoyproxy.envoy.config.core.v3.HeaderValueOption> headersList) | ||
| throws HeaderMutationDisallowedException { | ||
| ImmutableList.Builder<HeaderValueOption> 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(); | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Make the class and the constructor package-private.