Skip to content

Commit 9e6dfc4

Browse files
committed
xds: Add ext_authz response handling
Part 2 of the client-side ext_authz filter. Sits on top of #12493. Adds CheckResponseHandler, which interprets the CheckResponse from the authorization service. It evaluates OkHttpResponse vs DeniedHttpResponse, maps HTTP status codes to gRPC statuses, applies failure_mode_allow semantics when the authz server is unreachable, and validates decoder header mutations against the configured HeaderMutationRulesConfig. AuthzResponse is the resulting value object carrying the allow/deny decision, the gRPC status for denied calls, and any header/trailer mutations to apply.
1 parent d549690 commit 9e6dfc4

5 files changed

Lines changed: 691 additions & 0 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/*
2+
* Copyright 2025 The gRPC Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.grpc.xds.internal.extauthz;
18+
19+
import com.google.auto.value.AutoValue;
20+
import com.google.common.collect.ImmutableList;
21+
import io.grpc.Status;
22+
import io.grpc.xds.internal.headermutations.HeaderMutations;
23+
import java.util.Optional;
24+
25+
/**
26+
* Represents the outcome of an authorization check, detailing whether the request is allowed or
27+
* denied and including any associated headers or status information.
28+
*/
29+
@AutoValue
30+
abstract class AuthzResponse {
31+
32+
/** Defines the authorization decision. */
33+
public enum Decision {
34+
/** The request is permitted. */
35+
ALLOW,
36+
/** The request is rejected. */
37+
DENY,
38+
}
39+
40+
private static final HeaderMutations EMPTY_MUTATIONS =
41+
HeaderMutations.create(ImmutableList.of(), ImmutableList.of());
42+
43+
/**
44+
* Creates a builder for an ALLOW response, initializing with the specified request header
45+
* mutations.
46+
*/
47+
static Builder allow(HeaderMutations requestHeaderMutations) {
48+
return new AutoValue_AuthzResponse.Builder().setDecision(Decision.ALLOW)
49+
.setResponseHeaderMutations(EMPTY_MUTATIONS)
50+
.setRequestHeaderMutations(requestHeaderMutations);
51+
}
52+
53+
/** Creates a builder for a DENY response, initializing with the specified status. */
54+
static Builder deny(Status status) {
55+
return new AutoValue_AuthzResponse.Builder().setDecision(Decision.DENY)
56+
.setResponseHeaderMutations(EMPTY_MUTATIONS)
57+
.setRequestHeaderMutations(EMPTY_MUTATIONS)
58+
.setStatus(status);
59+
}
60+
61+
/** Returns the authorization decision. */
62+
public abstract Decision decision();
63+
64+
/**
65+
* For DENY decisions, this provides the status to be returned to the calling client. It is empty
66+
* for ALLOW decisions.
67+
*/
68+
public abstract Optional<Status> status();
69+
70+
/**
71+
* Returns mutations to be applied to the request headers. This is used for ALLOW decisions.
72+
*/
73+
public abstract HeaderMutations requestHeaderMutations();
74+
75+
/**
76+
* Returns mutations to be applied to the response headers. This is used for both ALLOW and DENY
77+
* decisions.
78+
*/
79+
public abstract HeaderMutations responseHeaderMutations();
80+
81+
/** Builder for creating {@link AuthzResponse} instances. */
82+
@AutoValue.Builder
83+
abstract static class Builder {
84+
85+
abstract Builder setDecision(Decision decision);
86+
87+
abstract Builder setStatus(Status status);
88+
89+
public abstract Builder setRequestHeaderMutations(
90+
HeaderMutations requestHeaderMutations);
91+
92+
public abstract Builder setResponseHeaderMutations(
93+
HeaderMutations responseHeaderMutations);
94+
95+
public abstract AuthzResponse build();
96+
}
97+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/*
2+
* Copyright 2025 The gRPC Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.grpc.xds.internal.extauthz;
18+
19+
import com.google.common.collect.ImmutableList;
20+
import io.envoyproxy.envoy.service.auth.v3.CheckResponse;
21+
import io.envoyproxy.envoy.service.auth.v3.DeniedHttpResponse;
22+
import io.envoyproxy.envoy.service.auth.v3.OkHttpResponse;
23+
import io.grpc.Metadata;
24+
import io.grpc.Status;
25+
import io.grpc.internal.GrpcUtil;
26+
import io.grpc.xds.internal.grpcservice.HeaderValue;
27+
import io.grpc.xds.internal.grpcservice.HeaderValueValidationUtils;
28+
import io.grpc.xds.internal.headermutations.HeaderMutationDisallowedException;
29+
import io.grpc.xds.internal.headermutations.HeaderMutationFilter;
30+
import io.grpc.xds.internal.headermutations.HeaderMutations;
31+
import io.grpc.xds.internal.headermutations.HeaderValueOption;
32+
import javax.annotation.concurrent.ThreadSafe;
33+
34+
/**
35+
* Handles the response from the external authorization service, processing it to determine the
36+
* authorization decision and applying any necessary header mutations.
37+
*/
38+
@ThreadSafe
39+
public class CheckResponseHandler {
40+
private final HeaderMutationFilter headerMutationFilter;
41+
42+
public CheckResponseHandler(HeaderMutationFilter headerMutationFilter) {
43+
this.headerMutationFilter = headerMutationFilter;
44+
}
45+
46+
AuthzResponse handleResponse(final CheckResponse response) {
47+
try {
48+
if (response.getStatus().getCode() == Status.Code.OK.value()) {
49+
return handleOkResponse(response);
50+
} else {
51+
return handleNotOkResponse(response);
52+
}
53+
} catch (HeaderMutationDisallowedException e) {
54+
return AuthzResponse.deny(e.getStatus()).build();
55+
}
56+
}
57+
58+
private AuthzResponse handleOkResponse(final CheckResponse response)
59+
throws HeaderMutationDisallowedException {
60+
if (!response.hasOkResponse()) {
61+
return AuthzResponse.allow(
62+
HeaderMutations.create(ImmutableList.of(), ImmutableList.of())).build();
63+
}
64+
OkHttpResponse okResponse = response.getOkResponse();
65+
CheckResponseMutations allowedMutations = buildHeaderMutationsFromOkResponse(okResponse);
66+
67+
return AuthzResponse.allow(allowedMutations.requestMutations())
68+
.setResponseHeaderMutations(allowedMutations.responseMutations()).build();
69+
}
70+
71+
private CheckResponseMutations buildHeaderMutationsFromOkResponse(OkHttpResponse okResponse)
72+
throws HeaderMutationDisallowedException {
73+
HeaderMutations requestMutations = HeaderMutations.create(
74+
convertHeaders(okResponse.getHeadersList()),
75+
ImmutableList.copyOf(okResponse.getHeadersToRemoveList()));
76+
HeaderMutations responseMutations = HeaderMutations.create(
77+
convertHeaders(okResponse.getResponseHeadersToAddList()),
78+
ImmutableList.of());
79+
return CheckResponseMutations.create(
80+
headerMutationFilter.filter(requestMutations),
81+
headerMutationFilter.filter(responseMutations));
82+
}
83+
84+
private AuthzResponse handleNotOkResponse(CheckResponse response)
85+
throws HeaderMutationDisallowedException {
86+
String baseMsg = "RPC denied by external authorization server";
87+
String outerMsg = response.getStatus().getMessage();
88+
String description = outerMsg.isEmpty() ? baseMsg : baseMsg + ": " + outerMsg;
89+
90+
if (!response.hasDeniedResponse()) {
91+
return AuthzResponse.deny(Status.PERMISSION_DENIED.withDescription(description)).build();
92+
}
93+
DeniedHttpResponse deniedResponse = response.getDeniedResponse();
94+
CheckResponseMutations allowedMutations =
95+
buildHeaderMutationsFromDeniedResponse(deniedResponse);
96+
97+
Status status = Status.PERMISSION_DENIED;
98+
if (deniedResponse.hasStatus()) {
99+
status = GrpcUtil.httpStatusToGrpcStatus(deniedResponse.getStatus().getCodeValue());
100+
}
101+
// Per gRFC A92: deniedResponse.body is ignored for gRPC (doesn't apply to gRPC).
102+
return AuthzResponse.deny(status.withDescription(description))
103+
.setResponseHeaderMutations(allowedMutations.responseMutations()).build();
104+
}
105+
106+
private CheckResponseMutations buildHeaderMutationsFromDeniedResponse(
107+
DeniedHttpResponse deniedResponse) throws HeaderMutationDisallowedException {
108+
HeaderMutations requestMutations =
109+
HeaderMutations.create(ImmutableList.of(), ImmutableList.of());
110+
HeaderMutations responseMutations = HeaderMutations.create(
111+
convertHeaders(deniedResponse.getHeadersList()),
112+
ImmutableList.of());
113+
return CheckResponseMutations.create(
114+
headerMutationFilter.filter(requestMutations),
115+
headerMutationFilter.filter(responseMutations));
116+
}
117+
118+
private ImmutableList<HeaderValueOption> convertHeaders(
119+
java.util.List<io.envoyproxy.envoy.config.core.v3.HeaderValueOption> headersList)
120+
throws HeaderMutationDisallowedException {
121+
ImmutableList.Builder<HeaderValueOption> builder = ImmutableList.builder();
122+
for (io.envoyproxy.envoy.config.core.v3.HeaderValueOption optionProto : headersList) {
123+
io.envoyproxy.envoy.config.core.v3.HeaderValue header = optionProto.getHeader();
124+
String key = header.getKey();
125+
HeaderValue internalHeader;
126+
if (key.endsWith(Metadata.BINARY_HEADER_SUFFIX)) {
127+
internalHeader = HeaderValue.create(key, header.getRawValue());
128+
} else {
129+
internalHeader = HeaderValue.create(key, header.getValue());
130+
}
131+
if (HeaderValueValidationUtils.isDisallowed(internalHeader)) {
132+
continue;
133+
}
134+
HeaderValueOption.HeaderAppendAction action;
135+
switch (optionProto.getAppendAction()) {
136+
case APPEND_IF_EXISTS_OR_ADD:
137+
action = HeaderValueOption.HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD;
138+
break;
139+
case ADD_IF_ABSENT:
140+
action = HeaderValueOption.HeaderAppendAction.ADD_IF_ABSENT;
141+
break;
142+
case OVERWRITE_IF_EXISTS_OR_ADD:
143+
action = HeaderValueOption.HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD;
144+
break;
145+
case OVERWRITE_IF_EXISTS:
146+
action = HeaderValueOption.HeaderAppendAction.OVERWRITE_IF_EXISTS;
147+
break;
148+
case UNRECOGNIZED:
149+
default:
150+
// Envoy Parity / Spec Parity: Unconditionally reject invalid/unrecognized append actions
151+
throw new HeaderMutationDisallowedException(
152+
"Unrecognized HeaderAppendAction: " + optionProto.getAppendAction());
153+
}
154+
builder
155+
.add(HeaderValueOption.create(internalHeader, action));
156+
}
157+
return builder.build();
158+
}
159+
}
160+
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Copyright 2025 The gRPC Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.grpc.xds.internal.extauthz;
18+
19+
import com.google.auto.value.AutoValue;
20+
import io.grpc.xds.internal.headermutations.HeaderMutations;
21+
22+
/**
23+
* A collection of header mutations for an external authorization response.
24+
* It contains separate mutations for request headers and response headers.
25+
*/
26+
@AutoValue
27+
abstract class CheckResponseMutations {
28+
29+
static CheckResponseMutations create(HeaderMutations requestMutations,
30+
HeaderMutations responseMutations) {
31+
return new AutoValue_CheckResponseMutations(requestMutations, responseMutations);
32+
}
33+
34+
public abstract HeaderMutations requestMutations();
35+
36+
public abstract HeaderMutations responseMutations();
37+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* Copyright 2025 The gRPC Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.grpc.xds.internal.extauthz;
18+
19+
import static com.google.common.truth.Truth.assertThat;
20+
21+
import com.google.common.collect.ImmutableList;
22+
import io.grpc.Status;
23+
import io.grpc.xds.internal.extauthz.AuthzResponse.Decision;
24+
import io.grpc.xds.internal.headermutations.HeaderMutations;
25+
import io.grpc.xds.internal.headermutations.HeaderValueOption;
26+
import org.junit.Test;
27+
import org.junit.runner.RunWith;
28+
import org.junit.runners.JUnit4;
29+
30+
@RunWith(JUnit4.class)
31+
public class AuthzResponseTest {
32+
@Test
33+
public void testAllow() {
34+
HeaderMutations requestMutations =
35+
HeaderMutations.create(ImmutableList.of(), ImmutableList.of());
36+
AuthzResponse response = AuthzResponse.allow(requestMutations).build();
37+
assertThat(response.decision()).isEqualTo(Decision.ALLOW);
38+
assertThat(response.requestHeaderMutations()).isEqualTo(requestMutations);
39+
assertThat(response.status()).isEmpty();
40+
assertThat(response.responseHeaderMutations().headers()).isEmpty();
41+
}
42+
43+
@Test
44+
public void testAllowWithHeaderMutations() {
45+
HeaderMutations requestMutations =
46+
HeaderMutations.create(ImmutableList.of(), ImmutableList.of());
47+
HeaderMutations responseMutations =
48+
HeaderMutations.create(
49+
ImmutableList.of(
50+
HeaderValueOption.create(
51+
io.grpc.xds.internal.grpcservice.HeaderValue.create("key", "value"),
52+
HeaderValueOption.HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD)),
53+
ImmutableList.of());
54+
AuthzResponse response =
55+
AuthzResponse.allow(requestMutations)
56+
.setResponseHeaderMutations(responseMutations)
57+
.build();
58+
assertThat(response.decision()).isEqualTo(Decision.ALLOW);
59+
assertThat(response.requestHeaderMutations()).isEqualTo(requestMutations);
60+
assertThat(response.responseHeaderMutations()).isEqualTo(responseMutations);
61+
}
62+
63+
@Test
64+
public void testDeny() {
65+
Status status = Status.PERMISSION_DENIED.withDescription("reason");
66+
AuthzResponse response = AuthzResponse.deny(status).build();
67+
assertThat(response.decision()).isEqualTo(Decision.DENY);
68+
assertThat(response.status()).hasValue(status);
69+
assertThat(response.requestHeaderMutations().headers()).isEmpty();
70+
assertThat(response.responseHeaderMutations().headers()).isEmpty();
71+
}
72+
73+
@Test
74+
public void testDenyWithResponseMutations() {
75+
Status status = Status.PERMISSION_DENIED.withDescription("reason");
76+
HeaderMutations responseMutations =
77+
HeaderMutations.create(
78+
ImmutableList.of(
79+
HeaderValueOption.create(
80+
io.grpc.xds.internal.grpcservice.HeaderValue.create("x-deny-info", "blocked"),
81+
HeaderValueOption.HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD)),
82+
ImmutableList.of());
83+
AuthzResponse response = AuthzResponse.deny(status)
84+
.setResponseHeaderMutations(responseMutations)
85+
.build();
86+
assertThat(response.decision()).isEqualTo(Decision.DENY);
87+
assertThat(response.status()).hasValue(status);
88+
assertThat(response.responseHeaderMutations()).isEqualTo(responseMutations);
89+
assertThat(response.requestHeaderMutations().headers()).isEmpty();
90+
}
91+
}

0 commit comments

Comments
 (0)