From 8107c1bc6d1c1ce12d55b65d10950dde33753639 Mon Sep 17 00:00:00 2001 From: Saurav Date: Thu, 23 Oct 2025 09:27:47 +0000 Subject: [PATCH 1/8] feat(xds): Update Envoy proto definitions and add ExtAuthz gRPC service This commit updates the Envoy proto definitions to a newer version and adds the generated gRPC code for the `envoy.service.auth.v3.Authorization` service. The updated proto definitions include changes to the `ext_authz` filter, `GrpcService` configuration, and other related components. This also includes new proto files for gRPC credentials and header mutation rules. The generated `AuthorizationGrpc.java` file provides the gRPC stub that will be used to communicate with the external authorization service. --- .gitignore | 3 + .../service/auth/v3/AuthorizationGrpc.java | 377 +++++++++++++ xds/third_party/envoy/import.sh | 13 +- .../envoy/config/bootstrap/v3/bootstrap.proto | 10 +- .../envoy/config/cluster/v3/cluster.proto | 17 +- .../mutation_rules/v3/mutation_rules.proto | 113 ++++ .../proto/envoy/config/core/v3/address.proto | 3 - .../envoy/config/core/v3/config_source.proto | 5 +- .../envoy/config/core/v3/grpc_service.proto | 23 +- .../envoy/config/core/v3/health_check.proto | 6 +- .../proto/envoy/config/core/v3/protocol.proto | 27 +- .../envoy/config/endpoint/v3/endpoint.proto | 5 +- .../config/endpoint/v3/load_report.proto | 6 +- .../listener/v3/listener_components.proto | 2 +- .../proto/envoy/config/metrics/v3/stats.proto | 4 +- .../envoy/config/overload/v3/overload.proto | 22 +- .../config/route/v3/route_components.proto | 94 +++- .../envoy/config/trace/v3/opentelemetry.proto | 9 +- .../proto/envoy/config/trace/v3/zipkin.proto | 85 ++- .../filters/http/ext_authz/v3/ext_authz.proto | 529 ++++++++++++++++++ .../v3/http_connection_manager.proto | 34 +- .../v3/access_token_credentials.proto | 19 + .../v3/google_default_credentials.proto | 17 + .../insecure/v3/insecure_credentials.proto | 17 + .../local/v3/local_credentials.proto | 17 + .../tls/v3/tls_credentials.proto | 27 + .../xds/v3/xds_credentials.proto | 21 + .../common/v3/common.proto | 24 +- .../service/auth/v3/attribute_context.proto | 222 ++++++++ .../envoy/service/auth/v3/external_auth.proto | 144 +++++ .../proto/envoy/type/matcher/v3/value.proto | 2 +- 31 files changed, 1828 insertions(+), 69 deletions(-) create mode 100644 xds/src/generated/thirdparty/grpc/io/envoyproxy/envoy/service/auth/v3/AuthorizationGrpc.java create mode 100644 xds/third_party/envoy/src/main/proto/envoy/config/common/mutation_rules/v3/mutation_rules.proto create mode 100644 xds/third_party/envoy/src/main/proto/envoy/extensions/filters/http/ext_authz/v3/ext_authz.proto create mode 100644 xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/call_credentials/access_token/v3/access_token_credentials.proto create mode 100644 xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/google_default/v3/google_default_credentials.proto create mode 100644 xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/insecure/v3/insecure_credentials.proto create mode 100644 xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/local/v3/local_credentials.proto create mode 100644 xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/tls/v3/tls_credentials.proto create mode 100644 xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/xds/v3/xds_credentials.proto create mode 100644 xds/third_party/envoy/src/main/proto/envoy/service/auth/v3/attribute_context.proto create mode 100644 xds/third_party/envoy/src/main/proto/envoy/service/auth/v3/external_auth.proto diff --git a/.gitignore b/.gitignore index 92a0e3d6d3a..b078d891adf 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,9 @@ MODULE.bazel.lock .gitignore bin +# VsCode +.vscode + # OS X .DS_Store diff --git a/xds/src/generated/thirdparty/grpc/io/envoyproxy/envoy/service/auth/v3/AuthorizationGrpc.java b/xds/src/generated/thirdparty/grpc/io/envoyproxy/envoy/service/auth/v3/AuthorizationGrpc.java new file mode 100644 index 00000000000..df9b7a3514b --- /dev/null +++ b/xds/src/generated/thirdparty/grpc/io/envoyproxy/envoy/service/auth/v3/AuthorizationGrpc.java @@ -0,0 +1,377 @@ +package io.envoyproxy.envoy.service.auth.v3; + +import static io.grpc.MethodDescriptor.generateFullMethodName; + +/** + *
+ * A generic interface for performing authorization check on incoming
+ * requests to a networked service.
+ * 
+ */ +@io.grpc.stub.annotations.GrpcGenerated +public final class AuthorizationGrpc { + + private AuthorizationGrpc() {} + + public static final java.lang.String SERVICE_NAME = "envoy.service.auth.v3.Authorization"; + + // Static method descriptors that strictly reflect the proto. + private static volatile io.grpc.MethodDescriptor getCheckMethod; + + @io.grpc.stub.annotations.RpcMethod( + fullMethodName = SERVICE_NAME + '/' + "Check", + requestType = io.envoyproxy.envoy.service.auth.v3.CheckRequest.class, + responseType = io.envoyproxy.envoy.service.auth.v3.CheckResponse.class, + methodType = io.grpc.MethodDescriptor.MethodType.UNARY) + public static io.grpc.MethodDescriptor getCheckMethod() { + io.grpc.MethodDescriptor getCheckMethod; + if ((getCheckMethod = AuthorizationGrpc.getCheckMethod) == null) { + synchronized (AuthorizationGrpc.class) { + if ((getCheckMethod = AuthorizationGrpc.getCheckMethod) == null) { + AuthorizationGrpc.getCheckMethod = getCheckMethod = + io.grpc.MethodDescriptor.newBuilder() + .setType(io.grpc.MethodDescriptor.MethodType.UNARY) + .setFullMethodName(generateFullMethodName(SERVICE_NAME, "Check")) + .setSampledToLocalTracing(true) + .setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller( + io.envoyproxy.envoy.service.auth.v3.CheckRequest.getDefaultInstance())) + .setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller( + io.envoyproxy.envoy.service.auth.v3.CheckResponse.getDefaultInstance())) + .setSchemaDescriptor(new AuthorizationMethodDescriptorSupplier("Check")) + .build(); + } + } + } + return getCheckMethod; + } + + /** + * Creates a new async stub that supports all call types for the service + */ + public static AuthorizationStub newStub(io.grpc.Channel channel) { + io.grpc.stub.AbstractStub.StubFactory factory = + new io.grpc.stub.AbstractStub.StubFactory() { + @java.lang.Override + public AuthorizationStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + return new AuthorizationStub(channel, callOptions); + } + }; + return AuthorizationStub.newStub(factory, channel); + } + + /** + * Creates a new blocking-style stub that supports all types of calls on the service + */ + public static AuthorizationBlockingV2Stub newBlockingV2Stub( + io.grpc.Channel channel) { + io.grpc.stub.AbstractStub.StubFactory factory = + new io.grpc.stub.AbstractStub.StubFactory() { + @java.lang.Override + public AuthorizationBlockingV2Stub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + return new AuthorizationBlockingV2Stub(channel, callOptions); + } + }; + return AuthorizationBlockingV2Stub.newStub(factory, channel); + } + + /** + * Creates a new blocking-style stub that supports unary and streaming output calls on the service + */ + public static AuthorizationBlockingStub newBlockingStub( + io.grpc.Channel channel) { + io.grpc.stub.AbstractStub.StubFactory factory = + new io.grpc.stub.AbstractStub.StubFactory() { + @java.lang.Override + public AuthorizationBlockingStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + return new AuthorizationBlockingStub(channel, callOptions); + } + }; + return AuthorizationBlockingStub.newStub(factory, channel); + } + + /** + * Creates a new ListenableFuture-style stub that supports unary calls on the service + */ + public static AuthorizationFutureStub newFutureStub( + io.grpc.Channel channel) { + io.grpc.stub.AbstractStub.StubFactory factory = + new io.grpc.stub.AbstractStub.StubFactory() { + @java.lang.Override + public AuthorizationFutureStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + return new AuthorizationFutureStub(channel, callOptions); + } + }; + return AuthorizationFutureStub.newStub(factory, channel); + } + + /** + *
+   * A generic interface for performing authorization check on incoming
+   * requests to a networked service.
+   * 
+ */ + public interface AsyncService { + + /** + *
+     * Performs authorization check based on the attributes associated with the
+     * incoming request, and returns status `OK` or not `OK`.
+     * 
+ */ + default void check(io.envoyproxy.envoy.service.auth.v3.CheckRequest request, + io.grpc.stub.StreamObserver responseObserver) { + io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getCheckMethod(), responseObserver); + } + } + + /** + * Base class for the server implementation of the service Authorization. + *
+   * A generic interface for performing authorization check on incoming
+   * requests to a networked service.
+   * 
+ */ + public static abstract class AuthorizationImplBase + implements io.grpc.BindableService, AsyncService { + + @java.lang.Override public final io.grpc.ServerServiceDefinition bindService() { + return AuthorizationGrpc.bindService(this); + } + } + + /** + * A stub to allow clients to do asynchronous rpc calls to service Authorization. + *
+   * A generic interface for performing authorization check on incoming
+   * requests to a networked service.
+   * 
+ */ + public static final class AuthorizationStub + extends io.grpc.stub.AbstractAsyncStub { + private AuthorizationStub( + io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + super(channel, callOptions); + } + + @java.lang.Override + protected AuthorizationStub build( + io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + return new AuthorizationStub(channel, callOptions); + } + + /** + *
+     * Performs authorization check based on the attributes associated with the
+     * incoming request, and returns status `OK` or not `OK`.
+     * 
+ */ + public void check(io.envoyproxy.envoy.service.auth.v3.CheckRequest request, + io.grpc.stub.StreamObserver responseObserver) { + io.grpc.stub.ClientCalls.asyncUnaryCall( + getChannel().newCall(getCheckMethod(), getCallOptions()), request, responseObserver); + } + } + + /** + * A stub to allow clients to do synchronous rpc calls to service Authorization. + *
+   * A generic interface for performing authorization check on incoming
+   * requests to a networked service.
+   * 
+ */ + public static final class AuthorizationBlockingV2Stub + extends io.grpc.stub.AbstractBlockingStub { + private AuthorizationBlockingV2Stub( + io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + super(channel, callOptions); + } + + @java.lang.Override + protected AuthorizationBlockingV2Stub build( + io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + return new AuthorizationBlockingV2Stub(channel, callOptions); + } + + /** + *
+     * Performs authorization check based on the attributes associated with the
+     * incoming request, and returns status `OK` or not `OK`.
+     * 
+ */ + public io.envoyproxy.envoy.service.auth.v3.CheckResponse check(io.envoyproxy.envoy.service.auth.v3.CheckRequest request) throws io.grpc.StatusException { + return io.grpc.stub.ClientCalls.blockingV2UnaryCall( + getChannel(), getCheckMethod(), getCallOptions(), request); + } + } + + /** + * A stub to allow clients to do limited synchronous rpc calls to service Authorization. + *
+   * A generic interface for performing authorization check on incoming
+   * requests to a networked service.
+   * 
+ */ + public static final class AuthorizationBlockingStub + extends io.grpc.stub.AbstractBlockingStub { + private AuthorizationBlockingStub( + io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + super(channel, callOptions); + } + + @java.lang.Override + protected AuthorizationBlockingStub build( + io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + return new AuthorizationBlockingStub(channel, callOptions); + } + + /** + *
+     * Performs authorization check based on the attributes associated with the
+     * incoming request, and returns status `OK` or not `OK`.
+     * 
+ */ + public io.envoyproxy.envoy.service.auth.v3.CheckResponse check(io.envoyproxy.envoy.service.auth.v3.CheckRequest request) { + return io.grpc.stub.ClientCalls.blockingUnaryCall( + getChannel(), getCheckMethod(), getCallOptions(), request); + } + } + + /** + * A stub to allow clients to do ListenableFuture-style rpc calls to service Authorization. + *
+   * A generic interface for performing authorization check on incoming
+   * requests to a networked service.
+   * 
+ */ + public static final class AuthorizationFutureStub + extends io.grpc.stub.AbstractFutureStub { + private AuthorizationFutureStub( + io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + super(channel, callOptions); + } + + @java.lang.Override + protected AuthorizationFutureStub build( + io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + return new AuthorizationFutureStub(channel, callOptions); + } + + /** + *
+     * Performs authorization check based on the attributes associated with the
+     * incoming request, and returns status `OK` or not `OK`.
+     * 
+ */ + public com.google.common.util.concurrent.ListenableFuture check( + io.envoyproxy.envoy.service.auth.v3.CheckRequest request) { + return io.grpc.stub.ClientCalls.futureUnaryCall( + getChannel().newCall(getCheckMethod(), getCallOptions()), request); + } + } + + private static final int METHODID_CHECK = 0; + + private static final class MethodHandlers implements + io.grpc.stub.ServerCalls.UnaryMethod, + io.grpc.stub.ServerCalls.ServerStreamingMethod, + io.grpc.stub.ServerCalls.ClientStreamingMethod, + io.grpc.stub.ServerCalls.BidiStreamingMethod { + private final AsyncService serviceImpl; + private final int methodId; + + MethodHandlers(AsyncService serviceImpl, int methodId) { + this.serviceImpl = serviceImpl; + this.methodId = methodId; + } + + @java.lang.Override + @java.lang.SuppressWarnings("unchecked") + public void invoke(Req request, io.grpc.stub.StreamObserver responseObserver) { + switch (methodId) { + case METHODID_CHECK: + serviceImpl.check((io.envoyproxy.envoy.service.auth.v3.CheckRequest) request, + (io.grpc.stub.StreamObserver) responseObserver); + break; + default: + throw new AssertionError(); + } + } + + @java.lang.Override + @java.lang.SuppressWarnings("unchecked") + public io.grpc.stub.StreamObserver invoke( + io.grpc.stub.StreamObserver responseObserver) { + switch (methodId) { + default: + throw new AssertionError(); + } + } + } + + public static final io.grpc.ServerServiceDefinition bindService(AsyncService service) { + return io.grpc.ServerServiceDefinition.builder(getServiceDescriptor()) + .addMethod( + getCheckMethod(), + io.grpc.stub.ServerCalls.asyncUnaryCall( + new MethodHandlers< + io.envoyproxy.envoy.service.auth.v3.CheckRequest, + io.envoyproxy.envoy.service.auth.v3.CheckResponse>( + service, METHODID_CHECK))) + .build(); + } + + private static abstract class AuthorizationBaseDescriptorSupplier + implements io.grpc.protobuf.ProtoFileDescriptorSupplier, io.grpc.protobuf.ProtoServiceDescriptorSupplier { + AuthorizationBaseDescriptorSupplier() {} + + @java.lang.Override + public com.google.protobuf.Descriptors.FileDescriptor getFileDescriptor() { + return io.envoyproxy.envoy.service.auth.v3.ExternalAuthProto.getDescriptor(); + } + + @java.lang.Override + public com.google.protobuf.Descriptors.ServiceDescriptor getServiceDescriptor() { + return getFileDescriptor().findServiceByName("Authorization"); + } + } + + private static final class AuthorizationFileDescriptorSupplier + extends AuthorizationBaseDescriptorSupplier { + AuthorizationFileDescriptorSupplier() {} + } + + private static final class AuthorizationMethodDescriptorSupplier + extends AuthorizationBaseDescriptorSupplier + implements io.grpc.protobuf.ProtoMethodDescriptorSupplier { + private final java.lang.String methodName; + + AuthorizationMethodDescriptorSupplier(java.lang.String methodName) { + this.methodName = methodName; + } + + @java.lang.Override + public com.google.protobuf.Descriptors.MethodDescriptor getMethodDescriptor() { + return getServiceDescriptor().findMethodByName(methodName); + } + } + + private static volatile io.grpc.ServiceDescriptor serviceDescriptor; + + public static io.grpc.ServiceDescriptor getServiceDescriptor() { + io.grpc.ServiceDescriptor result = serviceDescriptor; + if (result == null) { + synchronized (AuthorizationGrpc.class) { + result = serviceDescriptor; + if (result == null) { + serviceDescriptor = result = io.grpc.ServiceDescriptor.newBuilder(SERVICE_NAME) + .setSchemaDescriptor(new AuthorizationFileDescriptorSupplier()) + .addMethod(getCheckMethod()) + .build(); + } + } + } + return result; + } +} diff --git a/xds/third_party/envoy/import.sh b/xds/third_party/envoy/import.sh index ba657612586..ff1ed1048c4 100755 --- a/xds/third_party/envoy/import.sh +++ b/xds/third_party/envoy/import.sh @@ -17,7 +17,7 @@ set -e # import VERSION from the google internal copybara_version.txt for Envoy -VERSION=1128a52d227efb8c798478d293fdc05e8075ebcd +VERSION=b6df993feef0340391e6dbf6ad957ab42884ad05 DOWNLOAD_URL="https://github.com/envoyproxy/envoy/archive/${VERSION}.tar.gz" DOWNLOAD_BASE_DIR="envoy-${VERSION}" SOURCE_PROTO_BASE_DIR="${DOWNLOAD_BASE_DIR}/api" @@ -33,6 +33,7 @@ envoy/config/cluster/v3/circuit_breaker.proto envoy/config/cluster/v3/cluster.proto envoy/config/cluster/v3/filter.proto envoy/config/cluster/v3/outlier_detection.proto +envoy/config/common/mutation_rules/v3/mutation_rules.proto envoy/config/core/v3/address.proto envoy/config/core/v3/backoff.proto envoy/config/core/v3/base.proto @@ -74,12 +75,20 @@ envoy/config/trace/v3/zipkin.proto envoy/data/accesslog/v3/accesslog.proto envoy/extensions/clusters/aggregate/v3/cluster.proto envoy/extensions/filters/common/fault/v3/fault.proto +envoy/extensions/filters/http/ext_authz/v3/ext_authz.proto envoy/extensions/filters/http/fault/v3/fault.proto envoy/extensions/filters/http/rate_limit_quota/v3/rate_limit_quota.proto envoy/extensions/filters/http/gcp_authn/v3/gcp_authn.proto envoy/extensions/filters/http/rbac/v3/rbac.proto +envoy/extensions/filters/http/rate_limit_quota/v3/rate_limit_quota.proto envoy/extensions/filters/http/router/v3/router.proto envoy/extensions/filters/network/http_connection_manager/v3/http_connection_manager.proto +envoy/extensions/grpc_service/call_credentials/access_token/v3/access_token_credentials.proto +envoy/extensions/grpc_service/channel_credentials/google_default/v3/google_default_credentials.proto +envoy/extensions/grpc_service/channel_credentials/insecure/v3/insecure_credentials.proto +envoy/extensions/grpc_service/channel_credentials/local/v3/local_credentials.proto +envoy/extensions/grpc_service/channel_credentials/tls/v3/tls_credentials.proto +envoy/extensions/grpc_service/channel_credentials/xds/v3/xds_credentials.proto envoy/extensions/load_balancing_policies/client_side_weighted_round_robin/v3/client_side_weighted_round_robin.proto envoy/extensions/load_balancing_policies/common/v3/common.proto envoy/extensions/load_balancing_policies/least_request/v3/least_request.proto @@ -92,6 +101,8 @@ envoy/extensions/transport_sockets/tls/v3/cert.proto envoy/extensions/transport_sockets/tls/v3/common.proto envoy/extensions/transport_sockets/tls/v3/secret.proto envoy/extensions/transport_sockets/tls/v3/tls.proto +envoy/service/auth/v3/attribute_context.proto +envoy/service/auth/v3/external_auth.proto envoy/service/discovery/v3/ads.proto envoy/service/discovery/v3/discovery.proto envoy/service/load_stats/v3/lrs.proto diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/bootstrap/v3/bootstrap.proto b/xds/third_party/envoy/src/main/proto/envoy/config/bootstrap/v3/bootstrap.proto index bf65f3df45c..28b1eba6680 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/bootstrap/v3/bootstrap.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/bootstrap/v3/bootstrap.proto @@ -41,7 +41,7 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE; // ` for more detail. // Bootstrap :ref:`configuration overview `. -// [#next-free-field: 42] +// [#next-free-field: 43] message Bootstrap { option (udpa.annotations.versioning).previous_message_type = "envoy.config.bootstrap.v2.Bootstrap"; @@ -230,6 +230,14 @@ message Bootstrap { bool stats_flush_on_admin = 29 [(validate.rules).bool = {const: true}]; } + oneof stats_eviction { + // Optional duration to perform metric eviction. At every interval, during the stats flush + // the unused metrics are removed from the worker caches and the used metrics + // are marked as unused. Must be a multiple of the ``stats_flush_interval``. + google.protobuf.Duration stats_eviction_interval = 42 + [(validate.rules).duration = {gte {nanos: 1000000}}]; + } + // Optional watchdog configuration. // This is for a single watchdog configuration for the entire system. // Deprecated in favor of ``watchdogs`` which has finer granularity. diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/cluster/v3/cluster.proto b/xds/third_party/envoy/src/main/proto/envoy/config/cluster/v3/cluster.proto index 51180b1e855..c5112458a71 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/cluster/v3/cluster.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/cluster/v3/cluster.proto @@ -652,9 +652,10 @@ message Cluster { // If this is not set, we default to a merge window of 1000ms. To disable it, set the merge // window to 0. // - // Note: merging does not apply to cluster membership changes (e.g.: adds/removes); this is - // because merging those updates isn't currently safe. See - // https://github.com/envoyproxy/envoy/pull/3941. + // .. note:: + // Merging does not apply to cluster membership changes (e.g.: adds/removes); this is + // because merging those updates isn't currently safe. See + // https://github.com/envoyproxy/envoy/pull/3941. google.protobuf.Duration update_merge_window = 4; // If set to true, Envoy will :ref:`exclude ` new hosts @@ -816,12 +817,14 @@ message Cluster { string name = 1 [(validate.rules).string = {min_len: 1}]; // An optional alternative to the cluster name to be used for observability. This name is used - // emitting stats for the cluster and access logging the cluster name. This will appear as + // for emitting stats for the cluster and access logging the cluster name. This will appear as // additional information in configuration dumps of a cluster's current status as // :ref:`observability_name ` - // and as an additional tag "upstream_cluster.name" while tracing. Note: Any ``:`` in the name - // will be converted to ``_`` when emitting statistics. This should not be confused with - // :ref:`Router Filter Header `. + // and as an additional tag "upstream_cluster.name" while tracing. + // + // .. note:: + // Any ``:`` in the name will be converted to ``_`` when emitting statistics. This should not be confused with + // :ref:`Router Filter Header `. string alt_stat_name = 28 [(udpa.annotations.field_migrate).rename = "observability_name"]; oneof cluster_discovery_type { diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/common/mutation_rules/v3/mutation_rules.proto b/xds/third_party/envoy/src/main/proto/envoy/config/common/mutation_rules/v3/mutation_rules.proto new file mode 100644 index 00000000000..c015db21431 --- /dev/null +++ b/xds/third_party/envoy/src/main/proto/envoy/config/common/mutation_rules/v3/mutation_rules.proto @@ -0,0 +1,113 @@ +syntax = "proto3"; + +package envoy.config.common.mutation_rules.v3; + +import "envoy/config/core/v3/base.proto"; +import "envoy/type/matcher/v3/regex.proto"; +import "envoy/type/matcher/v3/string.proto"; + +import "google/protobuf/wrappers.proto"; + +import "udpa/annotations/status.proto"; +import "validate/validate.proto"; + +option java_package = "io.envoyproxy.envoy.config.common.mutation_rules.v3"; +option java_outer_classname = "MutationRulesProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/config/common/mutation_rules/v3;mutation_rulesv3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: Header mutation rules] + +// The HeaderMutationRules structure specifies what headers may be +// manipulated by a processing filter. This set of rules makes it +// possible to control which modifications a filter may make. +// +// By default, an external processing server may add, modify, or remove +// any header except for an "Envoy internal" header (which is typically +// denoted by an x-envoy prefix) or specific headers that may affect +// further filter processing: +// +// * ``host`` +// * ``:authority`` +// * ``:scheme`` +// * ``:method`` +// +// Every attempt to add, change, append, or remove a header will be +// tested against the rules here. Disallowed header mutations will be +// ignored unless ``disallow_is_error`` is set to true. +// +// Attempts to remove headers are further constrained -- regardless of the +// settings, system-defined headers (that start with ``:``) and the ``host`` +// header may never be removed. +// +// In addition, a counter will be incremented whenever a mutation is +// rejected. In the ext_proc filter, that counter is named +// ``rejected_header_mutations``. +// [#next-free-field: 8] +message HeaderMutationRules { + // By default, certain headers that could affect processing of subsequent + // filters or request routing cannot be modified. These headers are + // ``host``, ``:authority``, ``:scheme``, and ``:method``. Setting this parameter + // to true allows these headers to be modified as well. + google.protobuf.BoolValue allow_all_routing = 1; + + // If true, allow modification of envoy internal headers. By default, these + // start with ``x-envoy`` but this may be overridden in the ``Bootstrap`` + // configuration using the + // :ref:`header_prefix ` + // field. Default is false. + google.protobuf.BoolValue allow_envoy = 2; + + // If true, prevent modification of any system header, defined as a header + // that starts with a ``:`` character, regardless of any other settings. + // A processing server may still override the ``:status`` of an HTTP response + // using an ``ImmediateResponse`` message. Default is false. + google.protobuf.BoolValue disallow_system = 3; + + // If true, prevent modifications of all header values, regardless of any + // other settings. A processing server may still override the ``:status`` + // of an HTTP response using an ``ImmediateResponse`` message. Default is false. + google.protobuf.BoolValue disallow_all = 4; + + // If set, specifically allow any header that matches this regular + // expression. This overrides all other settings except for + // ``disallow_expression``. + type.matcher.v3.RegexMatcher allow_expression = 5; + + // If set, specifically disallow any header that matches this regular + // expression regardless of any other settings. + type.matcher.v3.RegexMatcher disallow_expression = 6; + + // If true, and if the rules in this list cause a header mutation to be + // disallowed, then the filter using this configuration will terminate the + // request with a 500 error. In addition, regardless of the setting of this + // parameter, any attempt to set, add, or modify a disallowed header will + // cause the ``rejected_header_mutations`` counter to be incremented. + // Default is false. + google.protobuf.BoolValue disallow_is_error = 7; +} + +// The HeaderMutation structure specifies an action that may be taken on HTTP +// headers. +message HeaderMutation { + message RemoveOnMatch { + // A string matcher that will be applied to the header key. If the header key + // matches, the header will be removed. + type.matcher.v3.StringMatcher key_matcher = 1 [(validate.rules).message = {required: true}]; + } + + oneof action { + option (validate.required) = true; + + // Remove the specified header if it exists. + string remove = 1 + [(validate.rules).string = {well_known_regex: HTTP_HEADER_VALUE strict: false}]; + + // Append new header by the specified HeaderValueOption. + core.v3.HeaderValueOption append = 2; + + // Remove the header if the key matches the specified string matcher. + RemoveOnMatch remove_on_match = 3; + } +} diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/address.proto b/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/address.proto index 56796fc721a..238494a09c7 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/address.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/address.proto @@ -105,9 +105,6 @@ message SocketAddress { // .. note:: // Setting this parameter requires Envoy to run with the ``CAP_NET_ADMIN`` capability. // - // .. note:: - // Currently only used for Listener sockets. - // // .. attention:: // Network namespaces are only configurable on Linux. Otherwise, this field has no effect. string network_namespace_filepath = 7; diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/config_source.proto b/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/config_source.proto index f0effd99e45..430562aa5bd 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/config_source.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/config_source.proto @@ -276,7 +276,8 @@ message ExtensionConfigSource { // to be supplied. bool apply_default_config_without_warming = 3; - // A set of permitted extension type URLs. Extension configuration updates are rejected - // if they do not match any type URL in the set. + // A set of permitted extension type URLs for the type encoded inside of the + // :ref:`TypedExtensionConfig `. Extension + // configuration updates are rejected if they do not match any type URL in the set. repeated string type_urls = 4 [(validate.rules).repeated = {min_items: 1}]; } diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/grpc_service.proto b/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/grpc_service.proto index 5fd7921a806..f8feb2f516f 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/grpc_service.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/grpc_service.proto @@ -64,7 +64,7 @@ message GrpcService { bool skip_envoy_headers = 5; } - // [#next-free-field: 9] + // [#next-free-field: 11] message GoogleGrpc { option (udpa.annotations.versioning).previous_message_type = "envoy.api.v2.core.GrpcService.GoogleGrpc"; @@ -249,16 +249,31 @@ message GrpcService { } // The target URI when using the `Google C++ gRPC client - // `_. SSL credentials will be supplied in - // :ref:`channel_credentials `. + // `_. string target_uri = 1 [(validate.rules).string = {min_len: 1}]; + // The channel credentials to use. See `channel credentials + // `_. + // Ignored if ``channel_credentials_plugin`` is set. ChannelCredentials channel_credentials = 2; - // A set of call credentials that can be composed with `channel credentials + // A list of channel credentials plugins. + // The data plane will iterate over the list in order and stop at the first credential type + // that it supports. This provides a mechanism for starting to use new credential types that + // are not yet supported by all data planes. + // [#not-implemented-hide:] + repeated google.protobuf.Any channel_credentials_plugin = 9; + + // The call credentials to use. See `channel credentials // `_. + // Ignored if ``call_credentials_plugin`` is set. repeated CallCredentials call_credentials = 3; + // A list of call credentials plugins. All supported plugins will be used. + // Unsupported plugin types will be ignored. + // [#not-implemented-hide:] + repeated google.protobuf.Any call_credentials_plugin = 10; + // The human readable prefix to use when emitting statistics for the gRPC // service. // diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/health_check.proto b/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/health_check.proto index fd4440d8fa5..a4ed6e91818 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/health_check.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/health_check.proto @@ -102,7 +102,8 @@ message HealthCheck { // ``/healthcheck``. string path = 2 [(validate.rules).string = {min_len: 1 well_known_regex: HTTP_HEADER_VALUE}]; - // [#not-implemented-hide:] HTTP specific payload. + // HTTP specific payload to be sent as the request body during health checking. + // If specified, the method should support a request body (POST, PUT, PATCH, etc.). Payload send = 3; // Specifies a list of HTTP expected responses to match in the first ``response_buffer_size`` bytes of the response body. @@ -161,7 +162,8 @@ message HealthCheck { type.matcher.v3.StringMatcher service_name_matcher = 11; // HTTP Method that will be used for health checking, default is "GET". - // GET, HEAD, POST, PUT, DELETE, OPTIONS, TRACE, PATCH methods are supported, but making request body is not supported. + // GET, HEAD, POST, PUT, DELETE, OPTIONS, TRACE, PATCH methods are supported. + // Request body payloads are supported for POST, PUT, PATCH, and OPTIONS methods only. // CONNECT method is disallowed because it is not appropriate for health check request. // If a non-200 response is expected by the method, it needs to be set in :ref:`expected_statuses `. RequestMethod method = 13 [(validate.rules).enum = {defined_only: true not_in: 6}]; diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/protocol.proto b/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/protocol.proto index edab4cd79c6..74fe641fe3a 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/protocol.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/protocol.proto @@ -77,7 +77,7 @@ message QuicProtocolOptions { [(validate.rules).uint32 = {lte: 16777216 gte: 1}]; // Similar to ``initial_stream_window_size``, but for connection-level - // flow-control. Valid values rage from 1 to 25165824 (24MB, maximum supported by QUICHE) and defaults + // flow-control. Valid values range from 1 to 25165824 (24MB, maximum supported by QUICHE) and defaults // to 25165824 (24 * 1024 * 1024). // // .. note:: @@ -111,10 +111,9 @@ message QuicProtocolOptions { // default 600s will be applied. // For internal corporate network, a long timeout is often fine. // But for client facing network, 30s is usually a good choice. - google.protobuf.Duration idle_network_timeout = 8 [(validate.rules).duration = { - lte {seconds: 600} - gte {seconds: 1} - }]; + // Do not add an upper bound here. A long idle timeout is useful for maintaining warm connections at non-front-line proxy for low QPS services." + google.protobuf.Duration idle_network_timeout = 8 + [(validate.rules).duration = {gte {seconds: 1}}]; // Maximum packet length for QUIC connections. It refers to the largest size of a QUIC packet that can be transmitted over the connection. // If not specified, one of the `default values in QUICHE `_ is used. @@ -276,7 +275,7 @@ message HttpProtocolOptions { // The default value for responses can be overridden by setting runtime key ``envoy.reloadable_features.max_response_headers_count``. // Downstream requests that exceed this limit will receive a 431 response for HTTP/1.x and cause a stream // reset for HTTP/2. - // Upstream responses that exceed this limit will result in a 503 response. + // Upstream responses that exceed this limit will result in a 502 response. google.protobuf.UInt32Value max_headers_count = 2 [(validate.rules).uint32 = {gte: 1}]; // The maximum size of response headers. @@ -420,7 +419,7 @@ message Http1ProtocolOptions { // envoy.reloadable_features.http1_use_balsa_parser. // See issue #21245. google.protobuf.BoolValue use_balsa_parser = 9 - [(xds.annotations.v3.field_status).work_in_progress = true]; + [deprecated = true, (envoy.annotations.deprecated_at_minor_version) = "3.0"]; // [#not-implemented-hide:] Hiding so that field can be removed. // If true, and BalsaParser is used (either `use_balsa_parser` above is true, @@ -503,7 +502,7 @@ message Http2ProtocolOptions { // `Maximum concurrent streams `_ // allowed for peer on one HTTP/2 connection. Valid values range from 1 to 2147483647 (2^31 - 1) - // and defaults to 2147483647. + // and defaults to 1024 for safety and should be sufficient for most use cases. // // For upstream connections, this also limits how many streams Envoy will initiate concurrently // on a single connection. If the limit is reached, Envoy may queue requests or establish @@ -517,8 +516,8 @@ message Http2ProtocolOptions { // `Initial stream-level flow-control window // `_ size. Valid values range from 65535 - // (2^16 - 1, HTTP/2 default) to 2147483647 (2^31 - 1, HTTP/2 maximum) and defaults to 268435456 - // (256 * 1024 * 1024). + // (2^16 - 1, HTTP/2 default) to 2147483647 (2^31 - 1, HTTP/2 maximum) and defaults to + // 16MiB (16 * 1024 * 1024). // // .. note:: // @@ -532,7 +531,7 @@ message Http2ProtocolOptions { [(validate.rules).uint32 = {lte: 2147483647 gte: 65535}]; // Similar to ``initial_stream_window_size``, but for connection-level flow-control - // window. Currently, this has the same minimum/maximum/default as ``initial_stream_window_size``. + // window. The default is 24MiB (24 * 1024 * 1024). google.protobuf.UInt32Value initial_connection_window_size = 4 [(validate.rules).uint32 = {lte: 2147483647 gte: 65535}]; @@ -674,7 +673,7 @@ message GrpcProtocolOptions { } // A message which allows using HTTP/3. -// [#next-free-field: 8] +// [#next-free-field: 9] message Http3ProtocolOptions { QuicProtocolOptions quic_protocol_options = 1; @@ -709,6 +708,10 @@ message Http3ProtocolOptions { // No huffman encoding, zero dynamic table capacity and no cookie crumbing. // This can be useful for trading off CPU vs bandwidth when an upstream HTTP/3 connection multiplexes multiple downstream connections. bool disable_qpack = 7; + + // Disables connection level flow control for HTTP/3 streams. This is useful in situations where the streams share the same connection + // but originate from different end-clients, so that each stream can make progress independently at non-front-line proxies. + bool disable_connection_flow_control_for_streams = 8; } // A message to control transformations to the :scheme header diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/endpoint/v3/endpoint.proto b/xds/third_party/envoy/src/main/proto/envoy/config/endpoint/v3/endpoint.proto index 894f68310a4..a149f6095c1 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/endpoint/v3/endpoint.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/endpoint/v3/endpoint.proto @@ -113,8 +113,9 @@ message ClusterLoadAssignment { // to determine the health of the priority level, or in other words assume each host has a weight of 1 for // this calculation. // - // Note: this is not currently implemented for - // :ref:`locality weighted load balancing `. + // .. note:: + // This is not currently implemented for + // :ref:`locality weighted load balancing `. bool weighted_priority_health = 6; } diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/endpoint/v3/load_report.proto b/xds/third_party/envoy/src/main/proto/envoy/config/endpoint/v3/load_report.proto index 32bbfe2d3f6..6d12765cef5 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/endpoint/v3/load_report.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/endpoint/v3/load_report.proto @@ -38,7 +38,8 @@ message UpstreamLocalityStats { // locality. uint64 total_successful_requests = 2; - // The total number of unfinished requests + // The total number of unfinished requests. A request can be an HTTP request + // or a TCP connection for a TCP connection pool. uint64 total_requests_in_progress = 3; // The total number of requests that failed due to errors at the endpoint, @@ -47,7 +48,8 @@ message UpstreamLocalityStats { // The total number of requests that were issued by this Envoy since // the last report. This information is aggregated over all the - // upstream endpoints in the locality. + // upstream endpoints in the locality. A request can be an HTTP request + // or a TCP connection for a TCP connection pool. uint64 total_issued_requests = 8; // The total number of connections in an established state at the time of the diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/listener/v3/listener_components.proto b/xds/third_party/envoy/src/main/proto/envoy/config/listener/v3/listener_components.proto index 33eb349fd06..cfa30afbb68 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/listener/v3/listener_components.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/listener/v3/listener_components.proto @@ -233,7 +233,7 @@ message FilterChain { google.protobuf.BoolValue use_proxy_proto = 4 [deprecated = true, (envoy.annotations.deprecated_at_minor_version) = "3.0"]; - // [#not-implemented-hide:] filter chain metadata. + // Filter chain metadata. core.v3.Metadata metadata = 5; // Optional custom transport socket implementation to use for downstream connections. diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/metrics/v3/stats.proto b/xds/third_party/envoy/src/main/proto/envoy/config/metrics/v3/stats.proto index e7d7f80d648..02bb23aec9d 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/metrics/v3/stats.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/metrics/v3/stats.proto @@ -298,10 +298,12 @@ message HistogramBucketSettings { // Each value is the upper bound of a bucket. Each bucket must be greater than 0 and unique. // The order of the buckets does not matter. repeated double buckets = 2 [(validate.rules).repeated = { - min_items: 1 unique: true items {double {gt: 0.0}} }]; + + // Initial number of bins for the ``circllhist`` thread local histogram per time series. Default value is 100. + google.protobuf.UInt32Value bins = 3 [(validate.rules).uint32 = {lte: 46082 gt: 0}]; } // Stats configuration proto schema for built-in ``envoy.stat_sinks.statsd`` sink. This sink does not support diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/overload/v3/overload.proto b/xds/third_party/envoy/src/main/proto/envoy/config/overload/v3/overload.proto index 1f267c1863d..b5bc2c4d830 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/overload/v3/overload.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/overload/v3/overload.proto @@ -109,6 +109,13 @@ message ScaleTimersOverloadActionConfig { // :ref:`HttpConnectionManager.common_http_protocol_options.max_connection_duration // `. HTTP_DOWNSTREAM_CONNECTION_MAX = 4; + + // Adjusts the timeout for the downstream codec to flush an ended stream. + // This affects the value of :ref:`RouteAction.flush_timeout + // ` and + // :ref:`HttpConnectionManager.stream_flush_timeout + // ` + HTTP_DOWNSTREAM_STREAM_FLUSH = 5; } message ScaleTimer { @@ -134,9 +141,16 @@ message OverloadAction { option (udpa.annotations.versioning).previous_message_type = "envoy.config.overload.v2alpha.OverloadAction"; - // The name of the overload action. This is just a well-known string that listeners can - // use for registering callbacks. Custom overload actions should be named using reverse - // DNS to ensure uniqueness. + // The name of the overload action. This is just a well-known string that + // listeners can use for registering callbacks. + // Valid known overload actions include: + // - envoy.overload_actions.stop_accepting_requests + // - envoy.overload_actions.disable_http_keepalive + // - envoy.overload_actions.stop_accepting_connections + // - envoy.overload_actions.reject_incoming_connections + // - envoy.overload_actions.shrink_heap + // - envoy.overload_actions.reduce_timeouts + // - envoy.overload_actions.reset_high_memory_stream string name = 1 [(validate.rules).string = {min_len: 1}]; // A set of triggers for this action. The state of the action is the maximum @@ -148,7 +162,7 @@ message OverloadAction { // in this list. repeated Trigger triggers = 2 [(validate.rules).repeated = {min_items: 1}]; - // Configuration for the action being instantiated. + // Configuration for the action being instantiated if applicable. google.protobuf.Any typed_config = 3; } diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/route/v3/route_components.proto b/xds/third_party/envoy/src/main/proto/envoy/config/route/v3/route_components.proto index 292e5b93558..6837ade69d8 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/route/v3/route_components.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/route/v3/route_components.proto @@ -41,7 +41,7 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE; // host header. This allows a single listener to service multiple top level domain path trees. Once // a virtual host is selected based on the domain, the routes are processed in order to see which // upstream cluster to route to or whether to perform a redirect. -// [#next-free-field: 25] +// [#next-free-field: 26] message VirtualHost { option (udpa.annotations.versioning).previous_message_type = "envoy.api.v2.route.VirtualHost"; @@ -205,10 +205,37 @@ message VirtualHost { // request header in retries initiated by per try timeouts. bool include_is_timeout_retry_header = 23; - // The maximum bytes which will be buffered for retries and shadowing. - // If set and a route-specific limit is not set, the bytes actually buffered will be the minimum - // value of this and the listener per_connection_buffer_limit_bytes. - google.protobuf.UInt32Value per_request_buffer_limit_bytes = 18; + // The maximum bytes which will be buffered for retries and shadowing. If set, the bytes actually buffered will be + // the minimum value of this and the listener ``per_connection_buffer_limit_bytes``. + // + // .. attention:: + // + // This field has been deprecated. Please use :ref:`request_body_buffer_limit + // ` instead. + // Only one of ``per_request_buffer_limit_bytes`` and ``request_body_buffer_limit`` could be set. + google.protobuf.UInt32Value per_request_buffer_limit_bytes = 18 + [deprecated = true, (envoy.annotations.deprecated_at_minor_version) = "3.0"]; + + // The maximum bytes which will be buffered for request bodies to support large request body + // buffering beyond the ``per_connection_buffer_limit_bytes``. + // + // This limit is specifically for the request body buffering and allows buffering larger payloads while maintaining + // flow control. + // + // Buffer limit precedence (from highest to lowest priority): + // + // 1. If ``request_body_buffer_limit`` is set, then ``request_body_buffer_limit`` will be used. + // 2. If :ref:`per_request_buffer_limit_bytes ` + // is set but ``request_body_buffer_limit`` is not, then ``min(per_request_buffer_limit_bytes, per_connection_buffer_limit_bytes)`` + // will be used. + // 3. If neither is set, then ``per_connection_buffer_limit_bytes`` will be used. + // + // For flow control chunk sizes, ``min(per_connection_buffer_limit_bytes, 16KB)`` will be used. + // + // Only one of :ref:`per_request_buffer_limit_bytes ` + // and ``request_body_buffer_limit`` could be set. + google.protobuf.UInt64Value request_body_buffer_limit = 25 + [(validate.rules).message = {required: false}]; // Specify a set of default request mirroring policies for every route under this virtual host. // It takes precedence over the route config mirror policy entirely. @@ -244,7 +271,7 @@ message RouteList { // // Envoy supports routing on HTTP method via :ref:`header matching // `. -// [#next-free-field: 20] +// [#next-free-field: 21] message Route { option (udpa.annotations.versioning).previous_message_type = "envoy.api.v2.route.Route"; @@ -341,7 +368,14 @@ message Route { // The maximum bytes which will be buffered for retries and shadowing. // If set, the bytes actually buffered will be the minimum value of this and the // listener per_connection_buffer_limit_bytes. - google.protobuf.UInt32Value per_request_buffer_limit_bytes = 16; + // + // .. attention:: + // + // This field has been deprecated. Please use :ref:`request_body_buffer_limit + // ` instead. + // Only one of ``per_request_buffer_limit_bytes`` and ``request_body_buffer_limit`` may be set. + google.protobuf.UInt32Value per_request_buffer_limit_bytes = 16 + [deprecated = true, (envoy.annotations.deprecated_at_minor_version) = "3.0"]; // The human readable prefix to use when emitting statistics for this endpoint. // The statistics are rooted at vhost..route.. @@ -357,6 +391,25 @@ message Route { // every application endpoint. This is both not easily maintainable and // statistics use a non-trivial amount of memory(approximately 1KiB per route). string stat_prefix = 19; + + // The maximum bytes which will be buffered for request bodies to support large request body + // buffering beyond the ``per_connection_buffer_limit_bytes``. + // + // This limit is specifically for the request body buffering and allows buffering larger payloads while maintaining + // flow control. + // + // Buffer limit precedence (from highest to lowest priority): + // + // 1. If ``request_body_buffer_limit`` is set: use ``request_body_buffer_limit`` + // 2. If :ref:`per_request_buffer_limit_bytes ` + // is set but ``request_body_buffer_limit`` is not: use ``min(per_request_buffer_limit_bytes, per_connection_buffer_limit_bytes)`` + // 3. If neither is set: use ``per_connection_buffer_limit_bytes`` + // + // For flow control chunk sizes, use ``min(per_connection_buffer_limit_bytes, 16KB)``. + // + // Only one of :ref:`per_request_buffer_limit_bytes ` + // and ``request_body_buffer_limit`` may be set. + google.protobuf.UInt64Value request_body_buffer_limit = 20; } // Compared to the :ref:`cluster ` field that specifies a @@ -365,6 +418,7 @@ message Route { // multiple upstream clusters along with weights that indicate the percentage of // traffic to be forwarded to each cluster. The router selects an upstream cluster based on the // weights. +// [#next-free-field: 6] message WeightedCluster { option (udpa.annotations.versioning).previous_message_type = "envoy.api.v2.route.WeightedCluster"; @@ -495,6 +549,10 @@ message WeightedCluster { // the process for the consistency. And the value is a unsigned number between 0 and UINT64_MAX. string header_name = 4 [(validate.rules).string = {well_known_regex: HTTP_HEADER_NAME strict: false}]; + + // When set to true, the hash policies will be used to generate the random value for weighted cluster selection. + // This could ensure consistent cluster picking across multiple proxy levels for weighted traffic. + google.protobuf.BoolValue use_hash_policy = 5; } } @@ -740,7 +798,7 @@ message CorsPolicy { google.protobuf.BoolValue forward_not_matching_preflights = 13; } -// [#next-free-field: 42] +// [#next-free-field: 43] message RouteAction { option (udpa.annotations.versioning).previous_message_type = "envoy.api.v2.route.RouteAction"; @@ -1265,8 +1323,28 @@ message RouteAction { // If the :ref:`overload action ` "envoy.overload_actions.reduce_timeouts" // is configured, this timeout is scaled according to the value for // :ref:`HTTP_DOWNSTREAM_STREAM_IDLE `. + // + // This timeout may also be used in place of ``flush_timeout`` in very specific cases. See the + // documentation for ``flush_timeout`` for more details. google.protobuf.Duration idle_timeout = 24; + // Specifies the codec stream flush timeout for the route. + // + // If not specified, the first preference is the global :ref:`stream_flush_timeout + // `, + // but only if explicitly configured. + // + // If neither the explicit HCM-wide flush timeout nor this route-specific flush timeout is configured, + // the route's stream idle timeout is reused for this timeout. This is for + // backwards compatibility since both behaviors were historically controlled by the one timeout. + // + // If the route also does not have an idle timeout configured, the global :ref:`stream_idle_timeout + // `. used, again + // for backwards compatibility. That timeout defaults to 5 minutes. + // + // A value of 0 via any of the above paths will completely disable the timeout for a given route. + google.protobuf.Duration flush_timeout = 42; + // Specifies how to send request over TLS early data. // If absent, allows `safe HTTP requests `_ to be sent on early data. // [#extension-category: envoy.route.early_data_policy] diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/trace/v3/opentelemetry.proto b/xds/third_party/envoy/src/main/proto/envoy/config/trace/v3/opentelemetry.proto index 59028326f22..5260d9bd6af 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/trace/v3/opentelemetry.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/trace/v3/opentelemetry.proto @@ -6,6 +6,8 @@ import "envoy/config/core/v3/extension.proto"; import "envoy/config/core/v3/grpc_service.proto"; import "envoy/config/core/v3/http_service.proto"; +import "google/protobuf/wrappers.proto"; + import "udpa/annotations/migrate.proto"; import "udpa/annotations/status.proto"; @@ -19,7 +21,7 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE; // Configuration for the OpenTelemetry tracer. // [#extension: envoy.tracers.opentelemetry] -// [#next-free-field: 6] +// [#next-free-field: 7] message OpenTelemetryConfig { // The upstream gRPC cluster that will receive OTLP traces. // Note that the tracer drops traces if the server does not read data fast enough. @@ -57,4 +59,9 @@ message OpenTelemetryConfig { // See: `OpenTelemetry sampler specification `_ // [#extension-category: envoy.tracers.opentelemetry.samplers] core.v3.TypedExtensionConfig sampler = 5; + + // Envoy caches the span in memory when the OpenTelemetry backend service is temporarily unavailable. + // This field specifies the maximum number of spans that can be cached. If not specified, the + // default is 1024. + google.protobuf.UInt32Value max_cache_size = 6; } diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/trace/v3/zipkin.proto b/xds/third_party/envoy/src/main/proto/envoy/config/trace/v3/zipkin.proto index 2d8f3195c31..7405c596ed5 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/trace/v3/zipkin.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/trace/v3/zipkin.proto @@ -2,13 +2,14 @@ syntax = "proto3"; package envoy.config.trace.v3; +import "envoy/config/core/v3/http_service.proto"; + import "google/protobuf/wrappers.proto"; import "envoy/annotations/deprecation.proto"; import "udpa/annotations/migrate.proto"; import "udpa/annotations/status.proto"; import "udpa/annotations/versioning.proto"; -import "validate/validate.proto"; option java_package = "io.envoyproxy.envoy.config.trace.v3"; option java_outer_classname = "ZipkinProto"; @@ -21,10 +22,22 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE; // Configuration for the Zipkin tracer. // [#extension: envoy.tracers.zipkin] -// [#next-free-field: 8] +// [#next-free-field: 10] message ZipkinConfig { option (udpa.annotations.versioning).previous_message_type = "envoy.config.trace.v2.ZipkinConfig"; + // Available trace context options for handling different trace header formats. + enum TraceContextOption { + // Use B3 headers only (default behavior). + USE_B3 = 0; + + // Enable B3 and W3C dual header support: + // - For downstream: Extract from B3 headers first, fallback to W3C traceparent if B3 is unavailable. + // - For upstream: Inject both B3 and W3C traceparent headers. + // When this option is NOT set, only B3 headers are used for both extraction and injection. + USE_B3_WITH_W3C_PROPAGATION = 1; + } + // Available Zipkin collector endpoint versions. enum CollectorEndpointVersion { // Zipkin API v1, JSON over HTTP. @@ -48,11 +61,17 @@ message ZipkinConfig { } // The cluster manager cluster that hosts the Zipkin collectors. - string collector_cluster = 1 [(validate.rules).string = {min_len: 1}]; + // Note: This field will be deprecated in future releases in favor of + // :ref:`collector_service `. + // Either this field or collector_service must be specified. + string collector_cluster = 1; // The API endpoint of the Zipkin service where the spans will be sent. When // using a standard Zipkin installation. - string collector_endpoint = 2 [(validate.rules).string = {min_len: 1}]; + // Note: This field will be deprecated in future releases in favor of + // :ref:`collector_service `. + // Required when using collector_cluster. + string collector_endpoint = 2; // Determines whether a 128bit trace id will be used when creating a new // trace instance. The default value is false, which will result in a 64 bit trace id being used. @@ -67,6 +86,8 @@ message ZipkinConfig { // Optional hostname to use when sending spans to the collector_cluster. Useful for collectors // that require a specific hostname. Defaults to :ref:`collector_cluster ` above. + // Note: This field will be deprecated in future releases in favor of + // :ref:`collector_service `. string collector_hostname = 6; // If this is set to true, then Envoy will be treated as an independent hop in trace chain. A complete span pair will be created for a single @@ -88,4 +109,60 @@ message ZipkinConfig { // Please use that ``spawn_upstream_span`` field to control the span creation. bool split_spans_for_request = 7 [deprecated = true, (envoy.annotations.deprecated_at_minor_version) = "3.0"]; + + // Determines which trace context format to use for trace header extraction and propagation. + // This controls both downstream request header extraction and upstream request header injection. + // Here is the spec for W3C trace headers: https://www.w3.org/TR/trace-context/ + // The default value is USE_B3 to maintain backward compatibility. + TraceContextOption trace_context_option = 8; + + // HTTP service configuration for the Zipkin collector. + // When specified, this configuration takes precedence over the legacy fields: + // collector_cluster, collector_endpoint, and collector_hostname. + // This provides a complete HTTP service configuration including cluster, URI, timeout, and headers. + // If not specified, the legacy fields above will be used for backward compatibility. + // + // Required fields when using collector_service: + // + // * ``http_uri.cluster`` - Must be specified and non-empty + // * ``http_uri.uri`` - Must be specified and non-empty + // * ``http_uri.timeout`` - Optional + // + // Full URI Support with Automatic Parsing: + // + // The ``uri`` field supports both path-only and full URI formats: + // + // .. code-block:: yaml + // + // tracing: + // provider: + // name: envoy.tracers.zipkin + // typed_config: + // "@type": type.googleapis.com/envoy.config.trace.v3.ZipkinConfig + // collector_service: + // http_uri: + // # Full URI format - hostname and path are extracted automatically + // uri: "https://zipkin-collector.example.com/api/v2/spans" + // cluster: zipkin + // timeout: 5s + // request_headers_to_add: + // - header: + // key: "X-Custom-Token" + // value: "your-custom-token" + // - header: + // key: "X-Service-ID" + // value: "your-service-id" + // + // URI Parsing Behavior: + // + // * Full URI: ``"https://zipkin-collector.example.com/api/v2/spans"`` + // + // * Hostname: ``zipkin-collector.example.com`` (sets HTTP ``Host`` header) + // * Path: ``/api/v2/spans`` (sets HTTP request path) + // + // * Path only: ``"/api/v2/spans"`` + // + // * Hostname: Uses cluster name as fallback + // * Path: ``/api/v2/spans`` + core.v3.HttpService collector_service = 9; } diff --git a/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/http/ext_authz/v3/ext_authz.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/http/ext_authz/v3/ext_authz.proto new file mode 100644 index 00000000000..7cf2aac64aa --- /dev/null +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/http/ext_authz/v3/ext_authz.proto @@ -0,0 +1,529 @@ +syntax = "proto3"; + +package envoy.extensions.filters.http.ext_authz.v3; + +import "envoy/config/common/mutation_rules/v3/mutation_rules.proto"; +import "envoy/config/core/v3/base.proto"; +import "envoy/config/core/v3/config_source.proto"; +import "envoy/config/core/v3/grpc_service.proto"; +import "envoy/config/core/v3/http_uri.proto"; +import "envoy/type/matcher/v3/metadata.proto"; +import "envoy/type/matcher/v3/string.proto"; +import "envoy/type/v3/http_status.proto"; + +import "google/protobuf/struct.proto"; +import "google/protobuf/wrappers.proto"; + +import "envoy/annotations/deprecation.proto"; +import "udpa/annotations/sensitive.proto"; +import "udpa/annotations/status.proto"; +import "udpa/annotations/versioning.proto"; +import "validate/validate.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3"; +option java_outer_classname = "ExtAuthzProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/ext_authz/v3;ext_authzv3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: External Authorization] +// External Authorization :ref:`configuration overview `. +// [#extension: envoy.filters.http.ext_authz] + +// [#next-free-field: 30] +message ExtAuthz { + option (udpa.annotations.versioning).previous_message_type = + "envoy.config.filter.http.ext_authz.v3.ExtAuthz"; + + reserved 4; + + reserved "use_alpha"; + + // External authorization service configuration. + oneof services { + // gRPC service configuration (default timeout: 200ms). + config.core.v3.GrpcService grpc_service = 1; + + // HTTP service configuration (default timeout: 200ms). + HttpService http_service = 3; + } + + // API version for ext_authz transport protocol. This describes the ext_authz gRPC endpoint and + // version of messages used on the wire. + config.core.v3.ApiVersion transport_api_version = 12 + [(validate.rules).enum = {defined_only: true}]; + + // Changes the filter's behavior on errors: + // + // #. When set to ``true``, the filter will ``accept`` the client request even if communication with + // the authorization service has failed, or if the authorization service has returned an HTTP 5xx + // error. + // + // #. When set to ``false``, the filter will ``reject`` client requests and return ``Forbidden`` + // if communication with the authorization service has failed, or if the authorization service + // has returned an HTTP 5xx error. + // + // Errors can always be tracked in the :ref:`stats `. + bool failure_mode_allow = 2; + + // When ``failure_mode_allow`` and ``failure_mode_allow_header_add`` are both set to ``true``, + // ``x-envoy-auth-failure-mode-allowed: true`` will be added to request headers if the communication + // with the authorization service has failed, or if the authorization service has returned a + // HTTP 5xx error. + bool failure_mode_allow_header_add = 19; + + // Enables the filter to buffer the client request body and send it within the authorization request. + // The ``x-envoy-auth-partial-body: false|true`` metadata header will be added to the authorization + // request indicating whether the body data is partial. + BufferSettings with_request_body = 5; + + // Clears the route cache in order to allow the external authorization service to correctly affect + // routing decisions. The filter clears all cached routes when: + // + // #. The field is set to ``true``. + // + // #. The status returned from the authorization service is an HTTP 200 or gRPC 0. + // + // #. At least one ``authorization response header`` is added to the client request, or is used to + // alter another client request header. + // + bool clear_route_cache = 6; + + // Sets the HTTP status that is returned to the client when the authorization server returns an error + // or cannot be reached. The default status is HTTP 403 Forbidden. + type.v3.HttpStatus status_on_error = 7; + + // When this is set to ``true``, the filter will check the :ref:`ext_authz response + // ` for invalid header and + // query parameter mutations. If the side stream response is invalid, it will send a local reply + // to the downstream request with status HTTP 500 Internal Server Error. + // + // .. note:: + // Both ``headers_to_remove`` and ``query_parameters_to_remove`` are validated, but invalid elements in + // those fields should not affect any headers and thus will not cause the filter to send a local reply. + // + // When set to ``false``, any invalid mutations will be visible to the rest of Envoy and may cause + // unexpected behavior. + // + // If you are using ext_authz with an untrusted ext_authz server, you should set this to ``true``. + bool validate_mutations = 24; + + // Specifies a list of metadata namespaces whose values, if present, will be passed to the + // ext_authz service. The :ref:`filter_metadata ` + // is passed as an opaque ``protobuf::Struct``. + // + // .. note:: + // This field applies exclusively to the gRPC ext_authz service and has no effect on the HTTP service. + // + // For example, if the ``jwt_authn`` filter is used and :ref:`payload_in_metadata + // ` is set, + // then the following will pass the jwt payload to the authorization server. + // + // .. code-block:: yaml + // + // metadata_context_namespaces: + // - envoy.filters.http.jwt_authn + // + repeated string metadata_context_namespaces = 8; + + // Specifies a list of metadata namespaces whose values, if present, will be passed to the + // ext_authz service. :ref:`typed_filter_metadata ` + // is passed as a ``protobuf::Any``. + // + // .. note:: + // This field applies exclusively to the gRPC ext_authz service and has no effect on the HTTP service. + // + // This works similarly to ``metadata_context_namespaces`` but allows Envoy and the ext_authz server to share + // the protobuf message definition in order to perform safe parsing. + // + repeated string typed_metadata_context_namespaces = 16; + + // Specifies a list of route metadata namespaces whose values, if present, will be passed to the + // ext_authz service at :ref:`route_metadata_context ` in + // :ref:`CheckRequest `. + // :ref:`filter_metadata ` is passed as an opaque ``protobuf::Struct``. + repeated string route_metadata_context_namespaces = 21; + + // Specifies a list of route metadata namespaces whose values, if present, will be passed to the + // ext_authz service at :ref:`route_metadata_context ` in + // :ref:`CheckRequest `. + // :ref:`typed_filter_metadata ` is passed as a ``protobuf::Any``. + repeated string route_typed_metadata_context_namespaces = 22; + + // Specifies if the filter is enabled. + // + // If :ref:`runtime_key ` is specified, + // Envoy will lookup the runtime key to get the percentage of requests to filter. + // + // If this field is not specified, the filter will be enabled for all requests. + config.core.v3.RuntimeFractionalPercent filter_enabled = 9; + + // Specifies if the filter is enabled with metadata matcher. + // If this field is not specified, the filter will be enabled for all requests. + type.matcher.v3.MetadataMatcher filter_enabled_metadata = 14; + + // Specifies whether to deny the requests when the filter is disabled. + // If :ref:`runtime_key ` is specified, + // Envoy will lookup the runtime key to determine whether to deny requests for filter-protected paths + // when the filter is disabled. If the filter is disabled in ``typed_per_filter_config`` for the path, + // requests will not be denied. + // + // If this field is not specified, all requests will be allowed when disabled. + // + // If a request is denied due to this setting, the response code in :ref:`status_on_error + // ` will + // be returned. + config.core.v3.RuntimeFeatureFlag deny_at_disable = 11; + + // Specifies if the peer certificate is sent to the external service. + // + // When this field is ``true``, Envoy will include the peer X.509 certificate, if available, in the + // :ref:`certificate`. + bool include_peer_certificate = 10; + + // Optional additional prefix to use when emitting statistics. This allows distinguishing + // emitted statistics between configured ``ext_authz`` filters in an HTTP filter chain. For example: + // + // .. code-block:: yaml + // + // http_filters: + // - name: envoy.filters.http.ext_authz + // typed_config: + // "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz + // stat_prefix: waf # This emits ext_authz.waf.ok, ext_authz.waf.denied, etc. + // - name: envoy.filters.http.ext_authz + // typed_config: + // "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz + // stat_prefix: blocker # This emits ext_authz.blocker.ok, ext_authz.blocker.denied, etc. + // + string stat_prefix = 13; + + // Optional labels that will be passed to :ref:`labels` in + // :ref:`destination`. + // The labels will be read from :ref:`metadata` with the specified key. + string bootstrap_metadata_labels_key = 15; + + // Check request to authorization server will include the client request headers that have a correspondent match + // in the :ref:`list `. If this option isn't specified, then + // all client request headers are included in the check request to a gRPC authorization server, whereas no client request headers + // (besides the ones allowed by default - see note below) are included in the check request to an HTTP authorization server. + // This inconsistency between gRPC and HTTP servers is to maintain backwards compatibility with legacy behavior. + // + // .. note:: + // + // For requests to an HTTP authorization server: in addition to the user's supplied matchers, ``Host``, ``Method``, ``Path``, + // ``Content-Length``, and ``Authorization`` are **additionally included** in the list. + // + // .. note:: + // + // For requests to an HTTP authorization server: the value of ``Content-Length`` will be set to ``0`` and the request to the + // authorization server will not have a message body. However, the check request can include the buffered + // client request body (controlled by :ref:`with_request_body + // ` setting); + // consequently, the value of ``Content-Length`` in the authorization request reflects the size of its payload. + // + // .. note:: + // + // This can be overridden by the field ``disallowed_headers`` below. That is, if a header + // matches for both ``allowed_headers`` and ``disallowed_headers``, the header will NOT be sent. + type.matcher.v3.ListStringMatcher allowed_headers = 17; + + // If set, specifically disallow any header in this list to be forwarded to the external + // authentication server. This overrides the above ``allowed_headers`` if a header matches both. + type.matcher.v3.ListStringMatcher disallowed_headers = 25; + + // Specifies if the TLS session level details like SNI are sent to the external service. + // + // When this field is ``true``, Envoy will include the SNI name used for TLSClientHello, if available, in the + // :ref:`tls_session`. + bool include_tls_session = 18; + + // Whether to increment cluster statistics (e.g. cluster..upstream_rq_*) on authorization failure. + // Defaults to ``true``. + google.protobuf.BoolValue charge_cluster_response_stats = 20; + + // Whether to encode the raw headers (i.e., unsanitized values and unconcatenated multi-line headers) + // in the authorization request. Works with both HTTP and gRPC clients. + // + // When this is set to ``true``, header values are not sanitized. Headers with the same key will also + // not be combined into a single, comma-separated header. + // Requests to gRPC services will populate the field + // :ref:`header_map`. + // Requests to HTTP services will be constructed with the unsanitized header values and preserved + // multi-line headers with the same key. + // + // If this field is set to ``false``, header values will be sanitized, with any non-UTF-8-compliant + // bytes replaced with ``'!'``. Headers with the same key will have their values concatenated into a + // single comma-separated header value. + // Requests to gRPC services will populate the field + // :ref:`headers`. + // Requests to HTTP services will have their header values sanitized and will not preserve + // multi-line headers with the same key. + // + // It is recommended to set this to ``true`` unless you rely on the previous behavior. + // + // It is set to ``false`` by default for backwards compatibility. + bool encode_raw_headers = 23; + + // Rules for what modifications an ext_authz server may make to the request headers before + // continuing decoding / forwarding upstream. + // + // If set to anything, enables header mutation checking against configured rules. Note that + // :ref:`HeaderMutationRules ` + // has defaults that change ext_authz behavior. Also note that if this field is set to anything, + // ext_authz can no longer append to :-prefixed headers. + // + // If empty, header mutation rule checking is completely disabled. + // + // Regardless of what is configured here, ext_authz cannot remove :-prefixed headers. + // + // This field and ``validate_mutations`` have different use cases. ``validate_mutations`` enables + // correctness checks for all header / query parameter mutations (e.g. for invalid characters). + // This field allows the filter to reject mutations to specific headers. + config.common.mutation_rules.v3.HeaderMutationRules decoder_header_mutation_rules = 26; + + // Enable or disable ingestion of dynamic metadata from the ext_authz service. + // + // If ``false``, the filter will ignore dynamic metadata injected by the ext_authz service. If the + // ext_authz service tries injecting dynamic metadata, the filter will log, increment the + // ``ignored_dynamic_metadata`` stat, then continue handling the response. + // + // If ``true``, the filter will ingest dynamic metadata entries as normal. + // + // If unset, defaults to ``true``. + google.protobuf.BoolValue enable_dynamic_metadata_ingestion = 27; + + // Additional metadata to be added to the filter state for logging purposes. The metadata will be + // added to StreamInfo's filter state under the namespace corresponding to the ext_authz filter + // name. + google.protobuf.Struct filter_metadata = 28; + + // When set to ``true``, the filter will emit per-stream stats for access logging. The filter state + // key will be the same as the filter name. + // + // If using Envoy gRPC, emits latency, bytes sent / received, upstream info, and upstream cluster + // info. If not using Envoy gRPC, emits only latency. Note that stats are ONLY added to filter + // state if a check request is actually made to an ext_authz service. + // + // If this is ``false`` the filter will not emit stats, but filter_metadata will still be respected if + // it has a value. + // + // Field ``latency_us`` is exposed for CEL and logging when using gRPC or HTTP service. + // Fields ``bytesSent`` and ``bytesReceived`` are exposed for CEL and logging only when using gRPC service. + bool emit_filter_state_stats = 29; +} + +// Configuration for buffering the request data. +message BufferSettings { + option (udpa.annotations.versioning).previous_message_type = + "envoy.config.filter.http.ext_authz.v2.BufferSettings"; + + // Sets the maximum size of a message body that the filter will hold in memory. Envoy will return + // ``HTTP 413`` and will *not* initiate the authorization process when the buffer reaches the size + // set in this field. Note that this setting will have precedence over :ref:`failure_mode_allow + // `. + uint32 max_request_bytes = 1 [(validate.rules).uint32 = {gt: 0}]; + + // When this field is ``true``, Envoy will buffer the message until ``max_request_bytes`` is reached. + // The authorization request will be dispatched and no 413 HTTP error will be returned by the + // filter. + bool allow_partial_message = 2; + + // If ``true``, the body sent to the external authorization service is set as raw bytes and populates + // :ref:`raw_body` + // in the HTTP request attribute context. Otherwise, :ref:`body + // ` will be populated + // with a UTF-8 string request body. + // + // This field only affects configurations using a :ref:`grpc_service + // `. In configurations that use + // an :ref:`http_service `, this + // has no effect. + bool pack_as_bytes = 3; +} + +// HttpService is used for raw HTTP communication between the filter and the authorization service. +// When configured, the filter will parse the client request and use these attributes to call the +// authorization server. Depending on the response, the filter may reject or accept the client +// request. Note that in any of these events, metadata can be added, removed or overridden by the +// filter: +// +// On authorization request, a list of allowed request headers may be supplied. See +// :ref:`allowed_headers +// ` +// for details. Additional headers metadata may be added to the authorization request. See +// :ref:`headers_to_add +// ` for +// details. +// +// On authorization response status ``HTTP 200 OK``, the filter will allow traffic to the upstream and +// additional headers metadata may be added to the original client request. See +// :ref:`allowed_upstream_headers +// ` +// for details. Additionally, the filter may add additional headers to the client's response. See +// :ref:`allowed_client_headers_on_success +// ` +// for details. +// +// On other authorization response statuses, the filter will not allow traffic. Additional headers +// metadata as well as body may be added to the client's response. See :ref:`allowed_client_headers +// ` +// for details. +// [#next-free-field: 9] +message HttpService { + option (udpa.annotations.versioning).previous_message_type = + "envoy.config.filter.http.ext_authz.v2.HttpService"; + + reserved 3, 4, 5, 6; + + // Sets the HTTP server URI which the authorization requests must be sent to. + config.core.v3.HttpUri server_uri = 1; + + // Sets a prefix to the value of authorization request header ``Path``. + string path_prefix = 2; + + // Settings used for controlling authorization request metadata. + AuthorizationRequest authorization_request = 7; + + // Settings used for controlling authorization response metadata. + AuthorizationResponse authorization_response = 8; +} + +message AuthorizationRequest { + option (udpa.annotations.versioning).previous_message_type = + "envoy.config.filter.http.ext_authz.v2.AuthorizationRequest"; + + // Authorization request includes the client request headers that have a corresponding match + // in the :ref:`list `. + // This field has been deprecated in favor of :ref:`allowed_headers + // `. + // + // .. note:: + // + // In addition to the user's supplied matchers, ``Host``, ``Method``, ``Path``, + // ``Content-Length``, and ``Authorization`` are **automatically included** in the list. + // + // .. note:: + // + // By default, the ``Content-Length`` header is set to ``0`` and the request to the authorization + // service has no message body. However, the authorization request *may* include the buffered + // client request body (controlled by :ref:`with_request_body + // ` + // setting); hence the value of its ``Content-Length`` reflects the size of its payload. + // + type.matcher.v3.ListStringMatcher allowed_headers = 1 + [deprecated = true, (envoy.annotations.deprecated_at_minor_version) = "3.0"]; + + // Sets a list of headers that will be included in the request to the authorization service. Note that + // client request headers with the same key will be overridden. + repeated config.core.v3.HeaderValue headers_to_add = 2; +} + +// [#next-free-field: 6] +message AuthorizationResponse { + option (udpa.annotations.versioning).previous_message_type = + "envoy.config.filter.http.ext_authz.v2.AuthorizationResponse"; + + // When this :ref:`list ` is set, authorization + // response headers that have a correspondent match will be added to the original client request. + // Note that coexistent headers will be overridden. + type.matcher.v3.ListStringMatcher allowed_upstream_headers = 1; + + // When this :ref:`list ` is set, authorization + // response headers that have a correspondent match will be added to the original client request. + // Note that coexistent headers will be appended. + type.matcher.v3.ListStringMatcher allowed_upstream_headers_to_append = 3; + + // When this :ref:`list ` is set, authorization + // response headers that have a correspondent match will be added to the client's response. Note + // that when this list is *not* set, all the authorization response headers, except ``Authority + // (Host)`` will be in the response to the client. When a header is included in this list, ``Path``, + // ``Status``, ``Content-Length``, ``WWWAuthenticate`` and ``Location`` are automatically added. + type.matcher.v3.ListStringMatcher allowed_client_headers = 2; + + // When this :ref:`list ` is set, authorization + // response headers that have a correspondent match will be added to the client's response when + // the authorization response itself is successful, i.e. not failed or denied. When this list is + // *not* set, no additional headers will be added to the client's response on success. + type.matcher.v3.ListStringMatcher allowed_client_headers_on_success = 4; + + // When this :ref:`list ` is set, authorization + // response headers that have a correspondent match will be emitted as dynamic metadata to be consumed + // by the next filter. This metadata lives in a namespace specified by the canonical name of extension filter + // that requires it: + // + // - :ref:`envoy.filters.http.ext_authz ` for HTTP filter. + // - :ref:`envoy.filters.network.ext_authz ` for network filter. + type.matcher.v3.ListStringMatcher dynamic_metadata_from_headers = 5; +} + +// Extra settings on a per virtualhost/route/weighted-cluster level. +message ExtAuthzPerRoute { + option (udpa.annotations.versioning).previous_message_type = + "envoy.config.filter.http.ext_authz.v2.ExtAuthzPerRoute"; + + oneof override { + option (validate.required) = true; + + // Disable the ext auth filter for this particular vhost or route. + // If disabled is specified in multiple per-filter-configs, the most specific one will be used. + // If the filter is disabled by default and this is set to ``false``, the filter will be enabled + // for this vhost or route. + bool disabled = 1; + + // Check request settings for this route. + CheckSettings check_settings = 2 [(validate.rules).message = {required: true}]; + } +} + +// Extra settings for the check request. +// [#next-free-field: 6] +message CheckSettings { + option (udpa.annotations.versioning).previous_message_type = + "envoy.config.filter.http.ext_authz.v2.CheckSettings"; + + // Context extensions to set on the CheckRequest's + // :ref:`AttributeContext.context_extensions` + // + // You can use this to provide extra context for the external authorization server on specific + // virtual hosts/routes. For example, adding a context extension on the virtual host level can + // give the ext-authz server information on what virtual host is used without needing to parse the + // host header. If CheckSettings is specified in multiple per-filter-configs, they will be merged + // in order, and the result will be used. + // + // Merge semantics for this field are such that keys from more specific configs override. + // + // .. note:: + // These settings are only applied to a filter configured with a + // :ref:`grpc_service`. + map context_extensions = 1 [(udpa.annotations.sensitive) = true]; + + // When set to ``true``, disable the configured :ref:`with_request_body + // ` for a specific route. + // + // Only one of ``disable_request_body_buffering`` and + // :ref:`with_request_body ` + // may be specified. + bool disable_request_body_buffering = 2; + + // Enable or override request body buffering, which is configured using the + // :ref:`with_request_body ` + // option for a specific route. + // + // Only one of ``with_request_body`` and + // :ref:`disable_request_body_buffering ` + // may be specified. + BufferSettings with_request_body = 3; + + // Override the external authorization service for this route. + // This allows different routes to use different external authorization service backends + // and service types (gRPC or HTTP). If specified, this overrides the filter-level service + // configuration regardless of the original service type. + oneof service_override { + // Override with a gRPC service configuration. + config.core.v3.GrpcService grpc_service = 4; + + // Override with an HTTP service configuration. + HttpService http_service = 5; + } +} diff --git a/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/network/http_connection_manager/v3/http_connection_manager.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/network/http_connection_manager/v3/http_connection_manager.proto index e0282af86e6..730e065e6c4 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/network/http_connection_manager/v3/http_connection_manager.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/network/http_connection_manager/v3/http_connection_manager.proto @@ -37,7 +37,7 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE; // HTTP connection manager :ref:`configuration overview `. // [#extension: envoy.filters.network.http_connection_manager] -// [#next-free-field: 59] +// [#next-free-field: 60] message HttpConnectionManager { option (udpa.annotations.versioning).previous_message_type = "envoy.config.filter.network.http_connection_manager.v2.HttpConnectionManager"; @@ -527,16 +527,6 @@ message HttpConnectionManager { // is terminated with a 408 Request Timeout error code if no upstream response // header has been received, otherwise a stream reset occurs. // - // This timeout also specifies the amount of time that Envoy will wait for the peer to open enough - // window to write any remaining stream data once the entirety of stream data (local end stream is - // true) has been buffered pending available window. In other words, this timeout defends against - // a peer that does not release enough window to completely write the stream, even though all - // data has been proxied within available flow control windows. If the timeout is hit in this - // case, the :ref:`tx_flush_timeout ` counter will be - // incremented. Note that :ref:`max_stream_duration - // ` does not apply to - // this corner case. - // // If the :ref:`overload action ` "envoy.overload_actions.reduce_timeouts" // is configured, this timeout is scaled according to the value for // :ref:`HTTP_DOWNSTREAM_STREAM_IDLE `. @@ -549,9 +539,29 @@ message HttpConnectionManager { // // A value of 0 will completely disable the connection manager stream idle // timeout, although per-route idle timeout overrides will continue to apply. + // + // This timeout is also used as the default value for :ref:`stream_flush_timeout + // `. google.protobuf.Duration stream_idle_timeout = 24 [(udpa.annotations.security).configure_for_untrusted_downstream = true]; + // The stream flush timeout for connections managed by the connection manager. + // + // If not specified, the value of stream_idle_timeout is used. This is for backwards compatibility + // since this was the original behavior. In essence this timeout is an override for the + // stream_idle_timeout that applies specifically to the end of stream flush case. + // + // This timeout specifies the amount of time that Envoy will wait for the peer to open enough + // window to write any remaining stream data once the entirety of stream data (local end stream is + // true) has been buffered pending available window. In other words, this timeout defends against + // a peer that does not release enough window to completely write the stream, even though all + // data has been proxied within available flow control windows. If the timeout is hit in this + // case, the :ref:`tx_flush_timeout ` counter will be + // incremented. Note that :ref:`max_stream_duration + // ` does not apply to + // this corner case. + google.protobuf.Duration stream_flush_timeout = 59; + // The amount of time that Envoy will wait for the entire request to be received. // The timer is activated when the request is initiated, and is disarmed when the last byte of the // request is sent upstream (i.e. all decoding filters have processed the request), OR when the @@ -1036,7 +1046,7 @@ message Rds { "envoy.config.filter.network.http_connection_manager.v2.Rds"; // Configuration source specifier for RDS. - config.core.v3.ConfigSource config_source = 1 [(validate.rules).message = {required: true}]; + config.core.v3.ConfigSource config_source = 1; // The name of the route configuration. This name will be passed to the RDS // API. This allows an Envoy configuration with multiple HTTP listeners (and diff --git a/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/call_credentials/access_token/v3/access_token_credentials.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/call_credentials/access_token/v3/access_token_credentials.proto new file mode 100644 index 00000000000..45ee3839e6f --- /dev/null +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/call_credentials/access_token/v3/access_token_credentials.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +package envoy.extensions.grpc_service.call_credentials.access_token.v3; + +import "udpa/annotations/status.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.grpc_service.call_credentials.access_token.v3"; +option java_outer_classname = "AccessTokenCredentialsProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/extensions/grpc_service/call_credentials/access_token/v3;access_tokenv3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: gRPC Access Token Credentials] + +// [#not-implemented-hide:] +message AccessTokenCredentials { + // The access token. + string token = 1; +} diff --git a/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/google_default/v3/google_default_credentials.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/google_default/v3/google_default_credentials.proto new file mode 100644 index 00000000000..77c3af41fdd --- /dev/null +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/google_default/v3/google_default_credentials.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +package envoy.extensions.grpc_service.channel_credentials.google_default.v3; + +import "udpa/annotations/status.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.google_default.v3"; +option java_outer_classname = "GoogleDefaultCredentialsProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/extensions/grpc_service/channel_credentials/google_default/v3;google_defaultv3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: gRPC Google Default Credentials] + +// [#not-implemented-hide:] +message GoogleDefaultCredentials { +} diff --git a/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/insecure/v3/insecure_credentials.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/insecure/v3/insecure_credentials.proto new file mode 100644 index 00000000000..70d58451e2d --- /dev/null +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/insecure/v3/insecure_credentials.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +package envoy.extensions.grpc_service.channel_credentials.insecure.v3; + +import "udpa/annotations/status.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.insecure.v3"; +option java_outer_classname = "InsecureCredentialsProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/extensions/grpc_service/channel_credentials/insecure/v3;insecurev3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: gRPC Insecure Credentials] + +// [#not-implemented-hide:] +message InsecureCredentials { +} diff --git a/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/local/v3/local_credentials.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/local/v3/local_credentials.proto new file mode 100644 index 00000000000..00514a0e847 --- /dev/null +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/local/v3/local_credentials.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +package envoy.extensions.grpc_service.channel_credentials.local.v3; + +import "udpa/annotations/status.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.local.v3"; +option java_outer_classname = "LocalCredentialsProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/extensions/grpc_service/channel_credentials/local/v3;localv3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: gRPC Local Credentials] + +// [#not-implemented-hide:] +message LocalCredentials { +} diff --git a/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/tls/v3/tls_credentials.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/tls/v3/tls_credentials.proto new file mode 100644 index 00000000000..f64c16bb684 --- /dev/null +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/tls/v3/tls_credentials.proto @@ -0,0 +1,27 @@ +syntax = "proto3"; + +package envoy.extensions.grpc_service.channel_credentials.tls.v3; + +import "envoy/extensions/transport_sockets/tls/v3/tls.proto"; + +import "udpa/annotations/status.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.tls.v3"; +option java_outer_classname = "TlsCredentialsProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/extensions/grpc_service/channel_credentials/tls/v3;tlsv3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: gRPC TLS Credentials] + +// [#not-implemented-hide:] +message TlsCredentials { + // The certificate provider instance for the root cert. Must be set. + transport_sockets.tls.v3.CommonTlsContext.CertificateProviderInstance root_certificate_provider = + 1; + + // The certificate provider instance for the identity cert. Optional; + // if unset, no identity certificate will be sent to the server. + transport_sockets.tls.v3.CommonTlsContext.CertificateProviderInstance + identity_certificate_provider = 2; +} diff --git a/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/xds/v3/xds_credentials.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/xds/v3/xds_credentials.proto new file mode 100644 index 00000000000..ba8d471dd49 --- /dev/null +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/xds/v3/xds_credentials.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package envoy.extensions.grpc_service.channel_credentials.xds.v3; + +import "google/protobuf/any.proto"; + +import "udpa/annotations/status.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.xds.v3"; +option java_outer_classname = "XdsCredentialsProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/extensions/grpc_service/channel_credentials/xds/v3;xdsv3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: gRPC xDS Credentials] + +// [#not-implemented-hide:] +message XdsCredentials { + // Fallback credentials. Required. + google.protobuf.Any fallback_credentials = 1; +} diff --git a/xds/third_party/envoy/src/main/proto/envoy/extensions/load_balancing_policies/common/v3/common.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/load_balancing_policies/common/v3/common.proto index 7868fb02b1a..22faf11b9c5 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/extensions/load_balancing_policies/common/v3/common.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/load_balancing_policies/common/v3/common.proto @@ -3,6 +3,7 @@ syntax = "proto3"; package envoy.extensions.load_balancing_policies.common.v3; import "envoy/config/core/v3/base.proto"; +import "envoy/config/route/v3/route_components.proto"; import "envoy/type/v3/percent.proto"; import "google/protobuf/duration.proto"; @@ -23,8 +24,17 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE; message LocalityLbConfig { // Configuration for :ref:`zone aware routing // `. - // [#next-free-field: 6] + // [#next-free-field: 7] message ZoneAwareLbConfig { + // Basis for computing per-locality percentages in zone-aware routing. + enum LocalityBasis { + // Use the number of healthy hosts in each locality. + HEALTHY_HOSTS_NUM = 0; + + // Use the weights of healthy hosts in each locality. + HEALTHY_HOSTS_WEIGHT = 1; + } + // Configures Envoy to always route requests to the local zone regardless of the // upstream zone structure. In Envoy's default configuration, traffic is distributed proportionally // across all upstream hosts while trying to maximize local routing when possible. The approach @@ -66,6 +76,12 @@ message LocalityLbConfig { [deprecated = true, (envoy.annotations.deprecated_at_minor_version) = "3.0"]; ForceLocalZone force_local_zone = 5; + + // Determines how locality percentages are computed: + // - HEALTHY_HOSTS_NUM: proportional to the count of healthy hosts. + // - HEALTHY_HOSTS_WEIGHT: proportional to the weights of healthy hosts. + // Default value is HEALTHY_HOSTS_NUM if unset. + LocalityBasis locality_basis = 6; } // Configuration for :ref:`locality weighted load balancing @@ -136,4 +152,10 @@ message ConsistentHashingLbConfig { // This is an O(N) algorithm, unlike other load balancers. Using a lower ``hash_balance_factor`` results in more hosts // being probed, so use a higher value if you require better performance. google.protobuf.UInt32Value hash_balance_factor = 2 [(validate.rules).uint32 = {gte: 100}]; + + // Specifies a list of hash policies to use for ring hash load balancing. If ``hash_policy`` is + // set, then + // :ref:`route level hash policy ` + // will be ignored. + repeated config.route.v3.RouteAction.HashPolicy hash_policy = 3; } diff --git a/xds/third_party/envoy/src/main/proto/envoy/service/auth/v3/attribute_context.proto b/xds/third_party/envoy/src/main/proto/envoy/service/auth/v3/attribute_context.proto new file mode 100644 index 00000000000..2c4fbb4b73e --- /dev/null +++ b/xds/third_party/envoy/src/main/proto/envoy/service/auth/v3/attribute_context.proto @@ -0,0 +1,222 @@ +syntax = "proto3"; + +package envoy.service.auth.v3; + +import "envoy/config/core/v3/address.proto"; +import "envoy/config/core/v3/base.proto"; + +import "google/protobuf/timestamp.proto"; + +import "udpa/annotations/migrate.proto"; +import "udpa/annotations/status.proto"; +import "udpa/annotations/versioning.proto"; + +option java_package = "io.envoyproxy.envoy.service.auth.v3"; +option java_outer_classname = "AttributeContextProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3;authv3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: Attribute context] + +// See :ref:`network filter configuration overview ` +// and :ref:`HTTP filter configuration overview `. + +// An attribute is a piece of metadata that describes an activity on a network. +// For example, the size of an HTTP request, or the status code of an HTTP response. +// +// Each attribute has a type and a name, which is logically defined as a proto message field +// of the ``AttributeContext``. The ``AttributeContext`` is a collection of individual attributes +// supported by Envoy authorization system. +// [#comment: The following items are left out of this proto +// Request.Auth field for JWTs +// Request.Api for api management +// Origin peer that originated the request +// Caching Protocol +// request_context return values to inject back into the filter chain +// peer.claims -- from X.509 extensions +// Configuration +// - field mask to send +// - which return values from request_context are copied back +// - which return values are copied into request_headers] +// [#next-free-field: 14] +message AttributeContext { + option (udpa.annotations.versioning).previous_message_type = + "envoy.service.auth.v2.AttributeContext"; + + // This message defines attributes for a node that handles a network request. + // The node can be either a service or an application that sends, forwards, + // or receives the request. Service peers should fill in the ``service``, + // ``principal``, and ``labels`` as appropriate. + // [#next-free-field: 6] + message Peer { + option (udpa.annotations.versioning).previous_message_type = + "envoy.service.auth.v2.AttributeContext.Peer"; + + // The address of the peer, this is typically the IP address. + // It can also be UDS path, or others. + config.core.v3.Address address = 1; + + // The canonical service name of the peer. + // It should be set to :ref:`the HTTP x-envoy-downstream-service-cluster + // ` + // If a more trusted source of the service name is available through mTLS/secure naming, it + // should be used. + string service = 2; + + // The labels associated with the peer. + // These could be pod labels for Kubernetes or tags for VMs. + // The source of the labels could be an X.509 certificate or other configuration. + map labels = 3; + + // The authenticated identity of this peer. + // For example, the identity associated with the workload such as a service account. + // If an X.509 certificate is used to assert the identity this field should be sourced from + // ``URI Subject Alternative Names``, ``DNS Subject Alternate Names`` or ``Subject`` in that order. + // The primary identity should be the principal. The principal format is issuer specific. + // + // Examples: + // + // - SPIFFE format is ``spiffe://trust-domain/path``. + // - Google account format is ``https://accounts.google.com/{userid}``. + string principal = 4; + + // The X.509 certificate used to authenticate the identify of this peer. + // When present, the certificate contents are encoded in URL and PEM format. + string certificate = 5; + } + + // Represents a network request, such as an HTTP request. + message Request { + option (udpa.annotations.versioning).previous_message_type = + "envoy.service.auth.v2.AttributeContext.Request"; + + // The timestamp when the proxy receives the first byte of the request. + google.protobuf.Timestamp time = 1; + + // Represents an HTTP request or an HTTP-like request. + HttpRequest http = 2; + } + + // This message defines attributes for an HTTP request. + // HTTP/1.x, HTTP/2, gRPC are all considered as HTTP requests. + // [#next-free-field: 14] + message HttpRequest { + option (udpa.annotations.versioning).previous_message_type = + "envoy.service.auth.v2.AttributeContext.HttpRequest"; + + // The unique ID for a request, which can be propagated to downstream + // systems. The ID should have low probability of collision + // within a single day for a specific service. + // For HTTP requests, it should be X-Request-ID or equivalent. + string id = 1; + + // The HTTP request method, such as ``GET``, ``POST``. + string method = 2; + + // The HTTP request headers. If multiple headers share the same key, they + // must be merged according to the HTTP spec. All header keys must be + // lower-cased, because HTTP header keys are case-insensitive. + // Header value is encoded as UTF-8 string. Non-UTF-8 characters will be replaced by "!". + // This field will not be set if + // :ref:`encode_raw_headers ` + // is set to true. + map headers = 3 + [(udpa.annotations.field_migrate).oneof_promotion = "headers_type"]; + + // A list of the raw HTTP request headers. This is used instead of + // :ref:`headers ` when + // :ref:`encode_raw_headers ` + // is set to true. + // + // Note that this is not actually a map type. ``header_map`` contains a single repeated field + // ``headers``. + // + // Here, only the ``key`` and ``raw_value`` fields will be populated for each HeaderValue, and + // that is only when + // :ref:`encode_raw_headers ` + // is set to true. + // + // Also, unlike the + // :ref:`headers ` + // field, headers with the same key are not combined into a single comma separated header. + config.core.v3.HeaderMap header_map = 13 + [(udpa.annotations.field_migrate).oneof_promotion = "headers_type"]; + + // The request target, as it appears in the first line of the HTTP request. This includes + // the URL path and query-string. No decoding is performed. + string path = 4; + + // The HTTP request ``Host`` or ``:authority`` header value. + string host = 5; + + // The HTTP URL scheme, such as ``http`` and ``https``. + string scheme = 6; + + // This field is always empty, and exists for compatibility reasons. The HTTP URL query is + // included in ``path`` field. + string query = 7; + + // This field is always empty, and exists for compatibility reasons. The URL fragment is + // not submitted as part of HTTP requests; it is unknowable. + string fragment = 8; + + // The HTTP request size in bytes. If unknown, it must be -1. + int64 size = 9; + + // The network protocol used with the request, such as "HTTP/1.0", "HTTP/1.1", or "HTTP/2". + // + // See :repo:`headers.h:ProtocolStrings ` for a list of all + // possible values. + string protocol = 10; + + // The HTTP request body. + string body = 11; + + // The HTTP request body in bytes. This is used instead of + // :ref:`body ` when + // :ref:`pack_as_bytes ` + // is set to true. + bytes raw_body = 12; + } + + // This message defines attributes for the underlying TLS session. + message TLSSession { + // SNI used for TLS session. + string sni = 1; + } + + // The source of a network activity, such as starting a TCP connection. + // In a multi hop network activity, the source represents the sender of the + // last hop. + Peer source = 1; + + // The destination of a network activity, such as accepting a TCP connection. + // In a multi hop network activity, the destination represents the receiver of + // the last hop. + Peer destination = 2; + + // Represents a network request, such as an HTTP request. + Request request = 4; + + // This is analogous to http_request.headers, however these contents will not be sent to the + // upstream server. Context_extensions provide an extension mechanism for sending additional + // information to the auth server without modifying the proto definition. It maps to the + // internal opaque context in the filter chain. + map context_extensions = 10; + + // Dynamic metadata associated with the request. + config.core.v3.Metadata metadata_context = 11; + + // Metadata associated with the selected route. + config.core.v3.Metadata route_metadata_context = 13; + + // TLS session details of the underlying connection. + // This is not populated by default and will be populated only if the ext_authz filter has + // been specifically configured to include this information. + // For HTTP ext_authz, that requires :ref:`include_tls_session ` + // to be set to true. + // For network ext_authz, that requires :ref:`include_tls_session ` + // to be set to true. + TLSSession tls_session = 12; +} diff --git a/xds/third_party/envoy/src/main/proto/envoy/service/auth/v3/external_auth.proto b/xds/third_party/envoy/src/main/proto/envoy/service/auth/v3/external_auth.proto new file mode 100644 index 00000000000..1f3ed5787d8 --- /dev/null +++ b/xds/third_party/envoy/src/main/proto/envoy/service/auth/v3/external_auth.proto @@ -0,0 +1,144 @@ +syntax = "proto3"; + +package envoy.service.auth.v3; + +import "envoy/config/core/v3/base.proto"; +import "envoy/service/auth/v3/attribute_context.proto"; +import "envoy/type/v3/http_status.proto"; + +import "google/protobuf/struct.proto"; +import "google/rpc/status.proto"; + +import "envoy/annotations/deprecation.proto"; +import "udpa/annotations/status.proto"; +import "udpa/annotations/versioning.proto"; + +option java_package = "io.envoyproxy.envoy.service.auth.v3"; +option java_outer_classname = "ExternalAuthProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3;authv3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: Authorization service] + +// The authorization service request messages used by external authorization :ref:`network filter +// ` and :ref:`HTTP filter `. + +// A generic interface for performing authorization check on incoming +// requests to a networked service. +service Authorization { + // Performs authorization check based on the attributes associated with the + // incoming request, and returns status `OK` or not `OK`. + rpc Check(CheckRequest) returns (CheckResponse) { + } +} + +message CheckRequest { + option (udpa.annotations.versioning).previous_message_type = "envoy.service.auth.v2.CheckRequest"; + + // The request attributes. + AttributeContext attributes = 1; +} + +// HTTP attributes for a denied response. +message DeniedHttpResponse { + option (udpa.annotations.versioning).previous_message_type = + "envoy.service.auth.v2.DeniedHttpResponse"; + + // This field allows the authorization service to send an HTTP response status code to the + // downstream client. If not set, Envoy sends ``403 Forbidden`` HTTP status code by default. + type.v3.HttpStatus status = 1; + + // This field allows the authorization service to send HTTP response headers + // to the downstream client. Note that the :ref:`append field in HeaderValueOption ` defaults to + // false when used in this message. + repeated config.core.v3.HeaderValueOption headers = 2; + + // This field allows the authorization service to send a response body data + // to the downstream client. + string body = 3; +} + +// HTTP attributes for an OK response. +// [#next-free-field: 9] +message OkHttpResponse { + option (udpa.annotations.versioning).previous_message_type = + "envoy.service.auth.v2.OkHttpResponse"; + + // HTTP entity headers in addition to the original request headers. This allows the authorization + // service to append, to add or to override headers from the original request before + // dispatching it to the upstream. Note that the :ref:`append field in HeaderValueOption ` defaults to + // false when used in this message. By setting the ``append`` field to ``true``, + // the filter will append the correspondent header value to the matched request header. + // By leaving ``append`` as false, the filter will either add a new header, or override an existing + // one if there is a match. + repeated config.core.v3.HeaderValueOption headers = 2; + + // HTTP entity headers to remove from the original request before dispatching + // it to the upstream. This allows the authorization service to act on auth + // related headers (like ``Authorization``), process them, and consume them. + // Under this model, the upstream will either receive the request (if it's + // authorized) or not receive it (if it's not), but will not see headers + // containing authorization credentials. + // + // Pseudo headers (such as ``:authority``, ``:method``, ``:path`` etc), as well as + // the header ``Host``, may not be removed as that would make the request + // malformed. If mentioned in ``headers_to_remove`` these special headers will + // be ignored. + // + // When using the HTTP service this must instead be set by the HTTP + // authorization service as a comma separated list like so: + // ``x-envoy-auth-headers-to-remove: one-auth-header, another-auth-header``. + repeated string headers_to_remove = 5; + + // This field has been deprecated in favor of :ref:`CheckResponse.dynamic_metadata + // `. Until it is removed, + // setting this field overrides :ref:`CheckResponse.dynamic_metadata + // `. + google.protobuf.Struct dynamic_metadata = 3 + [deprecated = true, (envoy.annotations.deprecated_at_minor_version) = "3.0"]; + + // This field allows the authorization service to send HTTP response headers + // to the downstream client on success. Note that the :ref:`append field in HeaderValueOption ` + // defaults to false when used in this message. + repeated config.core.v3.HeaderValueOption response_headers_to_add = 6; + + // This field allows the authorization service to set (and overwrite) query + // string parameters on the original request before it is sent upstream. + repeated config.core.v3.QueryParameter query_parameters_to_set = 7; + + // This field allows the authorization service to specify which query parameters + // should be removed from the original request before it is sent upstream. Each + // element in this list is a case-sensitive query parameter name to be removed. + repeated string query_parameters_to_remove = 8; +} + +// Intended for gRPC and Network Authorization servers ``only``. +message CheckResponse { + option (udpa.annotations.versioning).previous_message_type = + "envoy.service.auth.v2.CheckResponse"; + + // Status ``OK`` allows the request. Any other status indicates the request should be denied, and + // for HTTP filter, if not overridden by :ref:`denied HTTP response status ` + // Envoy sends ``403 Forbidden`` HTTP status code by default. + google.rpc.Status status = 1; + + // An message that contains HTTP response attributes. This message is + // used when the authorization service needs to send custom responses to the + // downstream client or, to modify/add request headers being dispatched to the upstream. + oneof http_response { + // Supplies http attributes for a denied response. + DeniedHttpResponse denied_response = 2; + + // Supplies http attributes for an ok response. + OkHttpResponse ok_response = 3; + } + + // Optional response metadata that will be emitted as dynamic metadata to be consumed by the next + // filter. This metadata lives in a namespace specified by the canonical name of extension filter + // that requires it: + // + // - :ref:`envoy.filters.http.ext_authz ` for HTTP filter. + // - :ref:`envoy.filters.network.ext_authz ` for network filter. + google.protobuf.Struct dynamic_metadata = 4; +} diff --git a/xds/third_party/envoy/src/main/proto/envoy/type/matcher/v3/value.proto b/xds/third_party/envoy/src/main/proto/envoy/type/matcher/v3/value.proto index d773c6057fc..8d65c457ccc 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/type/matcher/v3/value.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/type/matcher/v3/value.proto @@ -17,7 +17,7 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE; // [#protodoc-title: Value matcher] -// Specifies the way to match a ProtobufWkt::Value. Primitive values and ListValue are supported. +// Specifies the way to match a Protobuf::Value. Primitive values and ListValue are supported. // StructValue is not supported and is always not matched. // [#next-free-field: 8] message ValueMatcher { From 65f51c04c526ff2c278507b92b114468292eb3ff Mon Sep 17 00:00:00 2001 From: Saurav Date: Mon, 10 Nov 2025 21:52:52 +0000 Subject: [PATCH 2/8] feat(xds): Add configuration objects for ExtAuthz and GrpcService This commit introduces configuration objects for the external authorization (ExtAuthz) filter and the gRPC service it uses. These classes provide a structured, immutable representation of the configuration defined in the xDS protobuf messages. The main new classes are: - `ExtAuthzConfig`: Represents the configuration for the `ExtAuthz` filter, including settings for the gRPC service, header mutation rules, and other filter behaviors. - `GrpcServiceConfig`: Represents the configuration for a gRPC service, including the target URI, credentials, and other settings. - `HeaderMutationRulesConfig`: Represents the configuration for header mutation rules. This commit also includes parsers to create these configuration objects from the corresponding protobuf messages, as well as unit tests for the new classes. --- .../xds/internal/extauthz/ExtAuthzConfig.java | 250 ++++++++++++++ .../extauthz/ExtAuthzParseException.java | 34 ++ .../grpcservice/GrpcServiceConfig.java | 308 ++++++++++++++++++ .../GrpcServiceConfigChannelFactory.java | 26 ++ .../GrpcServiceParseException.java | 33 ++ .../InsecureGrpcChannelFactory.java | 43 +++ .../HeaderMutationRulesConfig.java | 77 +++++ .../internal/extauthz/ExtAuthzConfigTest.java | 259 +++++++++++++++ .../grpcservice/GrpcServiceConfigTest.java | 243 ++++++++++++++ .../InsecureGrpcChannelFactoryTest.java | 57 ++++ .../HeaderMutationRulesConfigTest.java | 84 +++++ 11 files changed, 1414 insertions(+) create mode 100644 xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzConfig.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzParseException.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfig.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigChannelFactory.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceParseException.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/grpcservice/InsecureGrpcChannelFactory.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfig.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzConfigTest.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigTest.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/grpcservice/InsecureGrpcChannelFactoryTest.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfigTest.java diff --git a/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzConfig.java b/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzConfig.java new file mode 100644 index 00000000000..e826f501d9c --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzConfig.java @@ -0,0 +1,250 @@ +/* + * 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.envoyproxy.envoy.config.common.mutation_rules.v3.HeaderMutationRules; +import io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz; +import io.grpc.Status; +import io.grpc.internal.GrpcUtil; +import io.grpc.xds.internal.MatcherParser; +import io.grpc.xds.internal.Matchers; +import io.grpc.xds.internal.grpcservice.GrpcServiceConfig; +import io.grpc.xds.internal.grpcservice.GrpcServiceParseException; +import io.grpc.xds.internal.headermutations.HeaderMutationRulesConfig; +import java.util.Optional; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +/** + * Represents the configuration for the external authorization (ext_authz) filter. This class + * encapsulates the settings defined in the + * {@link io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz} proto, providing a + * structured, immutable representation for use within gRPC. It includes configurations for the gRPC + * service used for authorization, header mutation rules, and other filter behaviors. + */ +@AutoValue +public abstract class ExtAuthzConfig { + + /** Creates a new builder for creating {@link ExtAuthzConfig} instances. */ + public static Builder builder() { + return new AutoValue_ExtAuthzConfig.Builder().allowedHeaders(ImmutableList.of()) + .disallowedHeaders(ImmutableList.of()).statusOnError(Status.PERMISSION_DENIED) + .filterEnabled(Matchers.FractionMatcher.create(100, 100)); + } + + /** + * Parses the {@link io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz} proto to + * create an {@link ExtAuthzConfig} instance. + * + * @param extAuthzProto The ext_authz proto to parse. + * @return An {@link ExtAuthzConfig} instance. + * @throws ExtAuthzParseException if the proto is invalid or contains unsupported features. + */ + public static ExtAuthzConfig fromProto(ExtAuthz extAuthzProto) throws ExtAuthzParseException { + if (!extAuthzProto.hasGrpcService()) { + throw new ExtAuthzParseException( + "unsupported ExtAuthz service type: only grpc_service is " + "supported"); + } + GrpcServiceConfig grpcServiceConfig; + try { + grpcServiceConfig = GrpcServiceConfig.fromProto(extAuthzProto.getGrpcService()); + } catch (GrpcServiceParseException e) { + throw new ExtAuthzParseException("Failed to parse GrpcService config: " + e.getMessage(), e); + } + Builder builder = builder().grpcService(grpcServiceConfig) + .failureModeAllow(extAuthzProto.getFailureModeAllow()) + .failureModeAllowHeaderAdd(extAuthzProto.getFailureModeAllowHeaderAdd()) + .includePeerCertificate(extAuthzProto.getIncludePeerCertificate()) + .denyAtDisable(extAuthzProto.getDenyAtDisable().getDefaultValue().getValue()); + + if (extAuthzProto.hasFilterEnabled()) { + builder.filterEnabled(parsePercent(extAuthzProto.getFilterEnabled().getDefaultValue())); + } + + if (extAuthzProto.hasStatusOnError()) { + builder.statusOnError( + GrpcUtil.httpStatusToGrpcStatus(extAuthzProto.getStatusOnError().getCodeValue())); + } + + if (extAuthzProto.hasAllowedHeaders()) { + builder.allowedHeaders(extAuthzProto.getAllowedHeaders().getPatternsList().stream() + .map(MatcherParser::parseStringMatcher).collect(ImmutableList.toImmutableList())); + } + + if (extAuthzProto.hasDisallowedHeaders()) { + builder.disallowedHeaders(extAuthzProto.getDisallowedHeaders().getPatternsList().stream() + .map(MatcherParser::parseStringMatcher).collect(ImmutableList.toImmutableList())); + } + + if (extAuthzProto.hasDecoderHeaderMutationRules()) { + builder.decoderHeaderMutationRules( + parseHeaderMutationRules(extAuthzProto.getDecoderHeaderMutationRules())); + } + + return builder.build(); + } + + /** + * The gRPC service configuration for the external authorization service. This is a required + * field. + * + * @see ExtAuthz#getGrpcService() + */ + public abstract GrpcServiceConfig grpcService(); + + /** + * Changes the filter's behavior on errors from the authorization service. If {@code true}, the + * filter will accept the request even if the authorization service fails or returns an error. + * + * @see ExtAuthz#getFailureModeAllow() + */ + public abstract boolean failureModeAllow(); + + /** + * Determines if the {@code x-envoy-auth-failure-mode-allowed} header is added to the request when + * {@link #failureModeAllow()} is true. + * + * @see ExtAuthz#getFailureModeAllowHeaderAdd() + */ + public abstract boolean failureModeAllowHeaderAdd(); + + /** + * Specifies if the peer certificate is sent to the external authorization service. + * + * @see ExtAuthz#getIncludePeerCertificate() + */ + public abstract boolean includePeerCertificate(); + + /** + * The gRPC status returned to the client when the authorization server returns an error or is + * unreachable. Defaults to {@code PERMISSION_DENIED}. + * + * @see io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz#getStatusOnError() + */ + public abstract Status statusOnError(); + + /** + * Specifies whether to deny requests when the filter is disabled. Defaults to {@code false}. + * + * @see ExtAuthz#getDenyAtDisable() + */ + public abstract boolean denyAtDisable(); + + /** + * The fraction of requests that will be checked by the authorization service. Defaults to all + * requests. + * + * @see ExtAuthz#getFilterEnabled() + */ + public abstract Matchers.FractionMatcher filterEnabled(); + + /** + * Specifies which request headers are sent to the authorization service. If not set, all headers + * are sent. + * + * @see ExtAuthz#getAllowedHeaders() + */ + public abstract ImmutableList allowedHeaders(); + + /** + * Specifies which request headers are not sent to the authorization service. This overrides + * {@link #allowedHeaders()}. + * + * @see ExtAuthz#getDisallowedHeaders() + */ + public abstract ImmutableList disallowedHeaders(); + + /** + * Rules for what modifications an ext_authz server may make to request headers. + * + * @see ExtAuthz#getDecoderHeaderMutationRules() + */ + public abstract Optional decoderHeaderMutationRules(); + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder grpcService(GrpcServiceConfig grpcService); + + public abstract Builder failureModeAllow(boolean failureModeAllow); + + public abstract Builder failureModeAllowHeaderAdd(boolean failureModeAllowHeaderAdd); + + public abstract Builder includePeerCertificate(boolean includePeerCertificate); + + public abstract Builder statusOnError(Status statusOnError); + + public abstract Builder denyAtDisable(boolean denyAtDisable); + + public abstract Builder filterEnabled(Matchers.FractionMatcher filterEnabled); + + public abstract Builder allowedHeaders(Iterable allowedHeaders); + + public abstract Builder disallowedHeaders(Iterable disallowedHeaders); + + public abstract Builder decoderHeaderMutationRules(HeaderMutationRulesConfig rules); + + public abstract ExtAuthzConfig build(); + } + + + private static Matchers.FractionMatcher parsePercent( + io.envoyproxy.envoy.type.v3.FractionalPercent proto) throws ExtAuthzParseException { + int denominator; + switch (proto.getDenominator()) { + case HUNDRED: + denominator = 100; + break; + case TEN_THOUSAND: + denominator = 10_000; + break; + case MILLION: + denominator = 1_000_000; + break; + case UNRECOGNIZED: + default: + throw new ExtAuthzParseException("Unknown denominator type: " + proto.getDenominator()); + } + return Matchers.FractionMatcher.create(proto.getNumerator(), denominator); + } + + private static HeaderMutationRulesConfig parseHeaderMutationRules(HeaderMutationRules proto) + throws ExtAuthzParseException { + HeaderMutationRulesConfig.Builder builder = HeaderMutationRulesConfig.builder(); + builder.disallowAll(proto.getDisallowAll().getValue()); + builder.disallowIsError(proto.getDisallowIsError().getValue()); + if (proto.hasAllowExpression()) { + builder.allowExpression( + parseRegex(proto.getAllowExpression().getRegex(), "allow_expression")); + } + if (proto.hasDisallowExpression()) { + builder.disallowExpression( + parseRegex(proto.getDisallowExpression().getRegex(), "disallow_expression")); + } + return builder.build(); + } + + private static Pattern parseRegex(String regex, String fieldName) throws ExtAuthzParseException { + try { + return Pattern.compile(regex); + } catch (PatternSyntaxException e) { + throw new ExtAuthzParseException( + "Invalid regex pattern for " + fieldName + ": " + e.getMessage(), e); + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzParseException.java b/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzParseException.java new file mode 100644 index 00000000000..78edea5c305 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzParseException.java @@ -0,0 +1,34 @@ +/* + * 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; + +/** + * A custom exception for signaling errors during the parsing of external authorization + * (ext_authz) configurations. + */ +public class ExtAuthzParseException extends Exception { + + private static final long serialVersionUID = 0L; + + public ExtAuthzParseException(String message) { + super(message); + } + + public ExtAuthzParseException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfig.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfig.java new file mode 100644 index 00000000000..da9be978f87 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfig.java @@ -0,0 +1,308 @@ +/* + * 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.grpcservice; + +import com.google.auth.oauth2.AccessToken; +import com.google.auth.oauth2.OAuth2Credentials; +import com.google.auto.value.AutoValue; +import com.google.common.io.BaseEncoding; +import com.google.protobuf.Any; +import com.google.protobuf.InvalidProtocolBufferException; +import io.envoyproxy.envoy.config.core.v3.GrpcService; +import io.envoyproxy.envoy.extensions.grpc_service.call_credentials.access_token.v3.AccessTokenCredentials; +import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.xds.v3.XdsCredentials; +import io.grpc.CallCredentials; +import io.grpc.ChannelCredentials; +import io.grpc.InsecureChannelCredentials; +import io.grpc.Metadata; +import io.grpc.alts.GoogleDefaultChannelCredentials; +import io.grpc.auth.MoreCallCredentials; +import io.grpc.xds.XdsChannelCredentials; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Optional; + + +/** + * A Java representation of the {@link io.envoyproxy.envoy.config.core.v3.GrpcService} proto, + * designed for parsing and internal use within gRPC. This class encapsulates the configuration for + * a gRPC service, including target URI, credentials, and other settings. The parsing logic adheres + * to the specifications outlined in + * A102: xDS GrpcService Support. This class is immutable and uses the AutoValue library for its + * implementation. + */ +@AutoValue +public abstract class GrpcServiceConfig { + + public static Builder builder() { + return new AutoValue_GrpcServiceConfig.Builder(); + } + + /** + * Parses the {@link io.envoyproxy.envoy.config.core.v3.GrpcService} proto to create a + * {@link GrpcServiceConfig} instance. This method adheres to gRFC A102, which specifies that only + * the {@code google_grpc} target specifier is supported. Other fields like {@code timeout} and + * {@code initial_metadata} are also parsed as per the gRFC. + * + * @param grpcServiceProto The proto to parse. + * @return A {@link GrpcServiceConfig} instance. + * @throws GrpcServiceParseException if the proto is invalid or uses unsupported features. + */ + public static GrpcServiceConfig fromProto(GrpcService grpcServiceProto) + throws GrpcServiceParseException { + if (!grpcServiceProto.hasGoogleGrpc()) { + throw new GrpcServiceParseException( + "Unsupported: GrpcService must have GoogleGrpc, got: " + grpcServiceProto); + } + GoogleGrpcConfig googleGrpcConfig = + GoogleGrpcConfig.fromProto(grpcServiceProto.getGoogleGrpc()); + + Builder builder = GrpcServiceConfig.builder().googleGrpc(googleGrpcConfig); + + if (!grpcServiceProto.getInitialMetadataList().isEmpty()) { + Metadata initialMetadata = new Metadata(); + for (io.envoyproxy.envoy.config.core.v3.HeaderValue header : grpcServiceProto + .getInitialMetadataList()) { + String key = header.getKey(); + if (key.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + initialMetadata.put(Metadata.Key.of(key, Metadata.BINARY_BYTE_MARSHALLER), + BaseEncoding.base64().decode(header.getValue())); + } else { + initialMetadata.put(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER), + header.getValue()); + } + } + builder.initialMetadata(initialMetadata); + } + + if (grpcServiceProto.hasTimeout()) { + com.google.protobuf.Duration timeout = grpcServiceProto.getTimeout(); + builder.timeout(Duration.ofSeconds(timeout.getSeconds(), timeout.getNanos())); + } + return builder.build(); + } + + public abstract GoogleGrpcConfig googleGrpc(); + + public abstract Optional timeout(); + + public abstract Optional initialMetadata(); + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder googleGrpc(GoogleGrpcConfig googleGrpc); + + public abstract Builder timeout(Duration timeout); + + public abstract Builder initialMetadata(Metadata initialMetadata); + + public abstract GrpcServiceConfig build(); + } + + /** + * Represents the configuration for a Google gRPC service, as defined in the + * {@link io.envoyproxy.envoy.config.core.v3.GrpcService.GoogleGrpc} proto. This class + * encapsulates settings specific to Google's gRPC implementation, such as target URI and + * credentials. The parsing of this configuration is guided by gRFC A102, which specifies how gRPC + * clients should interpret the GrpcService proto. + */ + @AutoValue + public abstract static class GoogleGrpcConfig { + + private static final String TLS_CREDENTIALS_TYPE_URL = + "type.googleapis.com/envoy.extensions.grpc_service.channel_credentials." + + "tls.v3.TlsCredentials"; + private static final String LOCAL_CREDENTIALS_TYPE_URL = + "type.googleapis.com/envoy.extensions.grpc_service.channel_credentials." + + "local.v3.LocalCredentials"; + private static final String XDS_CREDENTIALS_TYPE_URL = + "type.googleapis.com/envoy.extensions.grpc_service.channel_credentials." + + "xds.v3.XdsCredentials"; + private static final String INSECURE_CREDENTIALS_TYPE_URL = + "type.googleapis.com/envoy.extensions.grpc_service.channel_credentials." + + "insecure.v3.InsecureCredentials"; + private static final String GOOGLE_DEFAULT_CREDENTIALS_TYPE_URL = + "type.googleapis.com/envoy.extensions.grpc_service.channel_credentials." + + "google_default.v3.GoogleDefaultCredentials"; + + public static Builder builder() { + return new AutoValue_GrpcServiceConfig_GoogleGrpcConfig.Builder(); + } + + /** + * Parses the {@link io.envoyproxy.envoy.config.core.v3.GrpcService.GoogleGrpc} proto to create + * a {@link GoogleGrpcConfig} instance. + * + * @param googleGrpcProto The proto to parse. + * @return A {@link GoogleGrpcConfig} instance. + * @throws GrpcServiceParseException if the proto is invalid. + */ + public static GoogleGrpcConfig fromProto(GrpcService.GoogleGrpc googleGrpcProto) + throws GrpcServiceParseException { + + HashedChannelCredentials channelCreds = + extractChannelCredentials(googleGrpcProto.getChannelCredentialsPluginList()); + + CallCredentials callCreds = + extractCallCredentials(googleGrpcProto.getCallCredentialsPluginList()); + + return GoogleGrpcConfig.builder().target(googleGrpcProto.getTargetUri()) + .hashedChannelCredentials(channelCreds).callCredentials(callCreds).build(); + } + + public abstract String target(); + + public abstract HashedChannelCredentials hashedChannelCredentials(); + + public abstract CallCredentials callCredentials(); + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder target(String target); + + public abstract Builder hashedChannelCredentials(HashedChannelCredentials channelCredentials); + + public abstract Builder callCredentials(CallCredentials callCredentials); + + public abstract GoogleGrpcConfig build(); + } + + private static T getFirstSupported(List configs, Parser parser, + String configName) throws GrpcServiceParseException { + List errors = new ArrayList<>(); + for (U config : configs) { + try { + return parser.parse(config); + } catch (GrpcServiceParseException e) { + errors.add(e.getMessage()); + } + } + throw new GrpcServiceParseException( + "No valid supported " + configName + " found. Errors: " + errors); + } + + private static HashedChannelCredentials channelCredsFromProto(Any cred) + throws GrpcServiceParseException { + String typeUrl = cred.getTypeUrl(); + try { + switch (typeUrl) { + case GOOGLE_DEFAULT_CREDENTIALS_TYPE_URL: + return HashedChannelCredentials.of(GoogleDefaultChannelCredentials.create(), + cred.hashCode()); + case INSECURE_CREDENTIALS_TYPE_URL: + return HashedChannelCredentials.of(InsecureChannelCredentials.create(), + cred.hashCode()); + case XDS_CREDENTIALS_TYPE_URL: + XdsCredentials xdsConfig = cred.unpack(XdsCredentials.class); + HashedChannelCredentials fallbackCreds = + channelCredsFromProto(xdsConfig.getFallbackCredentials()); + return HashedChannelCredentials.of( + XdsChannelCredentials.create(fallbackCreds.channelCredentials()), cred.hashCode()); + case LOCAL_CREDENTIALS_TYPE_URL: + // TODO(sauravzg) : What's the java alternative to LocalCredentials. + throw new GrpcServiceParseException("LocalCredentials are not yet supported."); + case TLS_CREDENTIALS_TYPE_URL: + // TODO(sauravzg) : How to instantiate a TlsChannelCredentials from TlsCredentials + // proto? + throw new GrpcServiceParseException("TlsCredentials are not yet supported."); + default: + throw new GrpcServiceParseException("Unsupported channel credentials type: " + typeUrl); + } + } catch (InvalidProtocolBufferException e) { + // TODO(sauravzg): Add unit tests when we have a solution for TLS creds. + // This code is as of writing unreachable because all channel credential message + // types except TLS are empty messages. + throw new GrpcServiceParseException( + "Failed to parse channel credentials: " + e.getMessage()); + } + } + + private static CallCredentials callCredsFromProto(Any cred) throws GrpcServiceParseException { + try { + AccessTokenCredentials accessToken = cred.unpack(AccessTokenCredentials.class); + // TODO(sauravzg): Verify if the current behavior is per spec.The `AccessTokenCredentials` + // config doesn't have any timeout/refresh, so set the token to never expire. + return MoreCallCredentials.from(OAuth2Credentials + .create(new AccessToken(accessToken.getToken(), new Date(Long.MAX_VALUE)))); + } catch (InvalidProtocolBufferException e) { + throw new GrpcServiceParseException( + "Unsupported call credentials type: " + cred.getTypeUrl()); + } + } + + private static HashedChannelCredentials extractChannelCredentials( + List channelCredentialPlugins) throws GrpcServiceParseException { + return getFirstSupported(channelCredentialPlugins, GoogleGrpcConfig::channelCredsFromProto, + "channel_credentials"); + } + + private static CallCredentials extractCallCredentials(List callCredentialPlugins) + throws GrpcServiceParseException { + return getFirstSupported(callCredentialPlugins, GoogleGrpcConfig::callCredsFromProto, + "call_credentials"); + } + } + + /** + * A container for {@link ChannelCredentials} and a hash for the purpose of caching. + */ + @AutoValue + public abstract static class HashedChannelCredentials { + /** + * Creates a new {@link HashedChannelCredentials} instance. + * + * @param creds The channel credentials. + * @param hash The hash of the credentials. + * @return A new {@link HashedChannelCredentials} instance. + */ + public static HashedChannelCredentials of(ChannelCredentials creds, int hash) { + return new AutoValue_GrpcServiceConfig_HashedChannelCredentials(creds, hash); + } + + /** + * Returns the channel credentials. + */ + public abstract ChannelCredentials channelCredentials(); + + /** + * Returns the hash of the credentials. + */ + public abstract int hash(); + } + + /** + * Defines a generic interface for parsing a configuration of type {@code U} into a result of type + * {@code T}. This functional interface is used to abstract the parsing logic for different parts + * of the GrpcService configuration. + * + * @param The type of the object that will be returned after parsing. + * @param The type of the configuration object that will be parsed. + */ + private interface Parser { + + /** + * Parses the given configuration. + * + * @param config The configuration object to parse. + * @return The parsed object of type {@code T}. + * @throws GrpcServiceParseException if an error occurs during parsing. + */ + T parse(U config) throws GrpcServiceParseException; + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigChannelFactory.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigChannelFactory.java new file mode 100644 index 00000000000..0d02989eaa3 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigChannelFactory.java @@ -0,0 +1,26 @@ +/* + * 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.grpcservice; + +import io.grpc.ManagedChannel; + +/** + * A factory for creating {@link ManagedChannel}s from a {@link GrpcServiceConfig}. + */ +public interface GrpcServiceConfigChannelFactory { + ManagedChannel createChannel(GrpcServiceConfig config); +} diff --git a/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceParseException.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceParseException.java new file mode 100644 index 00000000000..319ad3d07e3 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceParseException.java @@ -0,0 +1,33 @@ +/* + * 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.grpcservice; + +/** + * Exception thrown when there is an error parsing the gRPC service config. + */ +public class GrpcServiceParseException extends Exception { + + private static final long serialVersionUID = 1L; + + public GrpcServiceParseException(String message) { + super(message); + } + + public GrpcServiceParseException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/grpcservice/InsecureGrpcChannelFactory.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/InsecureGrpcChannelFactory.java new file mode 100644 index 00000000000..d6325d43be4 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/InsecureGrpcChannelFactory.java @@ -0,0 +1,43 @@ +/* + * 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.grpcservice; + +import io.grpc.Grpc; +import io.grpc.ManagedChannel; + +/** + * An insecure implementation of {@link GrpcServiceConfigChannelFactory} that creates a plaintext + * channel. This is a stub implementation for channel creation until the GrpcService trusted server + * implementation is completely implemented. + */ +public final class InsecureGrpcChannelFactory implements GrpcServiceConfigChannelFactory { + + private static final InsecureGrpcChannelFactory INSTANCE = new InsecureGrpcChannelFactory(); + + private InsecureGrpcChannelFactory() {} + + public static InsecureGrpcChannelFactory getInstance() { + return INSTANCE; + } + + @Override + public ManagedChannel createChannel(GrpcServiceConfig config) { + GrpcServiceConfig.GoogleGrpcConfig googleGrpc = config.googleGrpc(); + return Grpc.newChannelBuilder(googleGrpc.target(), + googleGrpc.hashedChannelCredentials().channelCredentials()).build(); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfig.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfig.java new file mode 100644 index 00000000000..fd8048fdbd2 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfig.java @@ -0,0 +1,77 @@ +/* + * 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.headermutations; + +import com.google.auto.value.AutoValue; +import io.envoyproxy.envoy.config.common.mutation_rules.v3.HeaderMutationRules; +import java.util.Optional; +import java.util.regex.Pattern; + +/** + * Represents the configuration for header mutation rules, as defined in the + * {@link io.envoyproxy.envoy.config.common.mutation_rules.v3.HeaderMutationRules} proto. + */ +@AutoValue +public abstract class HeaderMutationRulesConfig { + /** Creates a new builder for creating {@link HeaderMutationRulesConfig} instances. */ + public static Builder builder() { + return new AutoValue_HeaderMutationRulesConfig.Builder().disallowAll(false) + .disallowIsError(false); + } + + /** + * If set, allows any header that matches this regular expression. + * + * @see HeaderMutationRules#getAllowExpression() + */ + public abstract Optional allowExpression(); + + /** + * If set, disallows any header that matches this regular expression. + * + * @see HeaderMutationRules#getDisallowExpression() + */ + public abstract Optional disallowExpression(); + + /** + * If true, disallows all header mutations. + * + * @see HeaderMutationRules#getDisallowAll() + */ + public abstract boolean disallowAll(); + + /** + * If true, disallows any header mutation that would result in an invalid header value. + * + * @see HeaderMutationRules#getDisallowIsError() + */ + public abstract boolean disallowIsError(); + + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder allowExpression(Pattern matcher); + + public abstract Builder disallowExpression(Pattern matcher); + + public abstract Builder disallowAll(boolean disallowAll); + + public abstract Builder disallowIsError(boolean disallowIsError); + + public abstract HeaderMutationRulesConfig build(); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzConfigTest.java b/xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzConfigTest.java new file mode 100644 index 00000000000..9b9a55b4079 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzConfigTest.java @@ -0,0 +1,259 @@ +/* + * 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.junit.Assert.fail; + +import com.google.protobuf.Any; +import com.google.protobuf.BoolValue; +import io.envoyproxy.envoy.config.common.mutation_rules.v3.HeaderMutationRules; +import io.envoyproxy.envoy.config.core.v3.HeaderValue; +import io.envoyproxy.envoy.config.core.v3.RuntimeFeatureFlag; +import io.envoyproxy.envoy.config.core.v3.RuntimeFractionalPercent; +import io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz; +import io.envoyproxy.envoy.extensions.grpc_service.call_credentials.access_token.v3.AccessTokenCredentials; +import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.google_default.v3.GoogleDefaultCredentials; +import io.envoyproxy.envoy.type.matcher.v3.ListStringMatcher; +import io.envoyproxy.envoy.type.matcher.v3.RegexMatcher; +import io.envoyproxy.envoy.type.matcher.v3.StringMatcher; +import io.envoyproxy.envoy.type.v3.FractionalPercent; +import io.envoyproxy.envoy.type.v3.FractionalPercent.DenominatorType; +import io.grpc.Status; +import io.grpc.xds.internal.Matchers; +import io.grpc.xds.internal.headermutations.HeaderMutationRulesConfig; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class ExtAuthzConfigTest { + + private static final Any GOOGLE_DEFAULT_CHANNEL_CREDS = + Any.pack(GoogleDefaultCredentials.newBuilder().build()); + private static final Any FAKE_ACCESS_TOKEN_CALL_CREDS = + Any.pack(AccessTokenCredentials.newBuilder().build()); + + private ExtAuthz.Builder extAuthzBuilder; + + @Before + public void setUp() { + extAuthzBuilder = ExtAuthz.newBuilder() + .setGrpcService(io.envoyproxy.envoy.config.core.v3.GrpcService.newBuilder() + .setGoogleGrpc(io.envoyproxy.envoy.config.core.v3.GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("test-cluster") + .addChannelCredentialsPlugin(GOOGLE_DEFAULT_CHANNEL_CREDS) + .addCallCredentialsPlugin(FAKE_ACCESS_TOKEN_CALL_CREDS).build()) + .build()); + } + + @Test + public void fromProto_missingGrpcService_throws() { + ExtAuthz extAuthz = ExtAuthz.newBuilder().build(); + try { + ExtAuthzConfig.fromProto(extAuthz); + fail("Expected ExtAuthzParseException"); + } catch (ExtAuthzParseException e) { + assertThat(e).hasMessageThat() + .isEqualTo("unsupported ExtAuthz service type: only grpc_service is supported"); + } + } + + @Test + public void fromProto_invalidGrpcService_throws() { + ExtAuthz extAuthz = ExtAuthz.newBuilder() + .setGrpcService(io.envoyproxy.envoy.config.core.v3.GrpcService.newBuilder().build()) + .build(); + try { + ExtAuthzConfig.fromProto(extAuthz); + fail("Expected ExtAuthzParseException"); + } catch (ExtAuthzParseException e) { + assertThat(e).hasMessageThat().startsWith("Failed to parse GrpcService config:"); + } + } + + @Test + public void fromProto_invalidAllowExpression_throws() { + ExtAuthz extAuthz = extAuthzBuilder + .setDecoderHeaderMutationRules(HeaderMutationRules.newBuilder() + .setAllowExpression(RegexMatcher.newBuilder().setRegex("[invalid").build()).build()) + .build(); + try { + ExtAuthzConfig.fromProto(extAuthz); + fail("Expected ExtAuthzParseException"); + } catch (ExtAuthzParseException e) { + assertThat(e).hasMessageThat().startsWith("Invalid regex pattern for allow_expression:"); + } + } + + @Test + public void fromProto_invalidDisallowExpression_throws() { + ExtAuthz extAuthz = extAuthzBuilder + .setDecoderHeaderMutationRules(HeaderMutationRules.newBuilder() + .setDisallowExpression(RegexMatcher.newBuilder().setRegex("[invalid").build()).build()) + .build(); + try { + ExtAuthzConfig.fromProto(extAuthz); + fail("Expected ExtAuthzParseException"); + } catch (ExtAuthzParseException e) { + assertThat(e).hasMessageThat().startsWith("Invalid regex pattern for disallow_expression:"); + } + } + + @Test + public void fromProto_success() throws ExtAuthzParseException { + ExtAuthz extAuthz = extAuthzBuilder + .setGrpcService(extAuthzBuilder.getGrpcServiceBuilder() + .setTimeout(com.google.protobuf.Duration.newBuilder().setSeconds(5).build()) + .addInitialMetadata(HeaderValue.newBuilder().setKey("key").setValue("value").build()) + .build()) + .setFailureModeAllow(true).setFailureModeAllowHeaderAdd(true) + .setIncludePeerCertificate(true) + .setStatusOnError( + io.envoyproxy.envoy.type.v3.HttpStatus.newBuilder().setCodeValue(403).build()) + .setDenyAtDisable( + RuntimeFeatureFlag.newBuilder().setDefaultValue(BoolValue.of(true)).build()) + .setFilterEnabled(RuntimeFractionalPercent.newBuilder() + .setDefaultValue(FractionalPercent.newBuilder().setNumerator(50) + .setDenominator(DenominatorType.TEN_THOUSAND).build()) + .build()) + .setAllowedHeaders(ListStringMatcher.newBuilder() + .addPatterns(StringMatcher.newBuilder().setExact("allowed-header").build()).build()) + .setDisallowedHeaders(ListStringMatcher.newBuilder() + .addPatterns(StringMatcher.newBuilder().setPrefix("disallowed-").build()).build()) + .setDecoderHeaderMutationRules(HeaderMutationRules.newBuilder() + .setAllowExpression(RegexMatcher.newBuilder().setRegex("allow.*").build()) + .setDisallowExpression(RegexMatcher.newBuilder().setRegex("disallow.*").build()) + .setDisallowAll(BoolValue.of(true)).setDisallowIsError(BoolValue.of(true)).build()) + .build(); + + ExtAuthzConfig config = ExtAuthzConfig.fromProto(extAuthz); + + assertThat(config.grpcService().googleGrpc().target()).isEqualTo("test-cluster"); + assertThat(config.grpcService().timeout().get().getSeconds()).isEqualTo(5); + assertThat(config.grpcService().initialMetadata().isPresent()).isTrue(); + assertThat(config.failureModeAllow()).isTrue(); + assertThat(config.failureModeAllowHeaderAdd()).isTrue(); + assertThat(config.includePeerCertificate()).isTrue(); + assertThat(config.statusOnError().getCode()).isEqualTo(Status.PERMISSION_DENIED.getCode()); + assertThat(config.statusOnError().getDescription()).isEqualTo("HTTP status code 403"); + assertThat(config.denyAtDisable()).isTrue(); + assertThat(config.filterEnabled()).isEqualTo(Matchers.FractionMatcher.create(50, 10_000)); + assertThat(config.allowedHeaders()).hasSize(1); + assertThat(config.allowedHeaders().get(0).matches("allowed-header")).isTrue(); + assertThat(config.disallowedHeaders()).hasSize(1); + assertThat(config.disallowedHeaders().get(0).matches("disallowed-foo")).isTrue(); + assertThat(config.decoderHeaderMutationRules().isPresent()).isTrue(); + HeaderMutationRulesConfig rules = config.decoderHeaderMutationRules().get(); + assertThat(rules.allowExpression().get().pattern()).isEqualTo("allow.*"); + assertThat(rules.disallowExpression().get().pattern()).isEqualTo("disallow.*"); + assertThat(rules.disallowAll()).isTrue(); + assertThat(rules.disallowIsError()).isTrue(); + } + + @Test + public void fromProto_saneDefaults() throws ExtAuthzParseException { + ExtAuthz extAuthz = extAuthzBuilder.build(); + + ExtAuthzConfig config = ExtAuthzConfig.fromProto(extAuthz); + + assertThat(config.failureModeAllow()).isFalse(); + assertThat(config.failureModeAllowHeaderAdd()).isFalse(); + assertThat(config.includePeerCertificate()).isFalse(); + assertThat(config.statusOnError()).isEqualTo(Status.PERMISSION_DENIED); + assertThat(config.denyAtDisable()).isFalse(); + assertThat(config.filterEnabled()).isEqualTo(Matchers.FractionMatcher.create(100, 100)); + assertThat(config.allowedHeaders()).isEmpty(); + assertThat(config.disallowedHeaders()).isEmpty(); + assertThat(config.decoderHeaderMutationRules().isPresent()).isFalse(); + } + + @Test + public void fromProto_headerMutationRules_allowExpressionOnly() throws ExtAuthzParseException { + ExtAuthz extAuthz = extAuthzBuilder + .setDecoderHeaderMutationRules(HeaderMutationRules.newBuilder() + .setAllowExpression(RegexMatcher.newBuilder().setRegex("allow.*").build()).build()) + .build(); + + ExtAuthzConfig config = ExtAuthzConfig.fromProto(extAuthz); + + assertThat(config.decoderHeaderMutationRules().isPresent()).isTrue(); + HeaderMutationRulesConfig rules = config.decoderHeaderMutationRules().get(); + assertThat(rules.allowExpression().get().pattern()).isEqualTo("allow.*"); + assertThat(rules.disallowExpression().isPresent()).isFalse(); + } + + @Test + public void fromProto_headerMutationRules_disallowExpressionOnly() throws ExtAuthzParseException { + ExtAuthz extAuthz = extAuthzBuilder + .setDecoderHeaderMutationRules(HeaderMutationRules.newBuilder() + .setDisallowExpression(RegexMatcher.newBuilder().setRegex("disallow.*").build()) + .build()) + .build(); + + ExtAuthzConfig config = ExtAuthzConfig.fromProto(extAuthz); + + assertThat(config.decoderHeaderMutationRules().isPresent()).isTrue(); + HeaderMutationRulesConfig rules = config.decoderHeaderMutationRules().get(); + assertThat(rules.allowExpression().isPresent()).isFalse(); + assertThat(rules.disallowExpression().get().pattern()).isEqualTo("disallow.*"); + } + + @Test + public void fromProto_filterEnabled_hundred() throws ExtAuthzParseException { + ExtAuthz extAuthz = extAuthzBuilder + .setFilterEnabled(RuntimeFractionalPercent.newBuilder().setDefaultValue(FractionalPercent + .newBuilder().setNumerator(25).setDenominator(DenominatorType.HUNDRED).build()).build()) + .build(); + + ExtAuthzConfig config = ExtAuthzConfig.fromProto(extAuthz); + + assertThat(config.filterEnabled()).isEqualTo(Matchers.FractionMatcher.create(25, 100)); + } + + @Test + public void fromProto_filterEnabled_million() throws ExtAuthzParseException { + ExtAuthz extAuthz = extAuthzBuilder + .setFilterEnabled( + RuntimeFractionalPercent.newBuilder().setDefaultValue(FractionalPercent.newBuilder() + .setNumerator(123456).setDenominator(DenominatorType.MILLION).build()).build()) + .build(); + + ExtAuthzConfig config = ExtAuthzConfig.fromProto(extAuthz); + + assertThat(config.filterEnabled()) + .isEqualTo(Matchers.FractionMatcher.create(123456, 1_000_000)); + } + + @Test + public void fromProto_filterEnabled_unrecognizedDenominator() { + ExtAuthz extAuthz = extAuthzBuilder + .setFilterEnabled(RuntimeFractionalPercent.newBuilder() + .setDefaultValue( + FractionalPercent.newBuilder().setNumerator(1).setDenominatorValue(4).build()) + .build()) + .build(); + + try { + ExtAuthzConfig.fromProto(extAuthz); + fail("Expected ExtAuthzParseException"); + } catch (ExtAuthzParseException e) { + assertThat(e).hasMessageThat().isEqualTo("Unknown denominator type: UNRECOGNIZED"); + } + } +} \ No newline at end of file diff --git a/xds/src/test/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigTest.java b/xds/src/test/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigTest.java new file mode 100644 index 00000000000..7a506220973 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfigTest.java @@ -0,0 +1,243 @@ +/* + * 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.grpcservice; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.common.io.BaseEncoding; +import com.google.protobuf.Any; +import com.google.protobuf.Duration; +import io.envoyproxy.envoy.config.core.v3.GrpcService; +import io.envoyproxy.envoy.config.core.v3.HeaderValue; +import io.envoyproxy.envoy.extensions.grpc_service.call_credentials.access_token.v3.AccessTokenCredentials; +import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.google_default.v3.GoogleDefaultCredentials; +import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.insecure.v3.InsecureCredentials; +import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.local.v3.LocalCredentials; +import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.xds.v3.XdsCredentials; +import io.grpc.InsecureChannelCredentials; +import io.grpc.Metadata; +import java.nio.charset.StandardCharsets; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class GrpcServiceConfigTest { + + @Test + public void fromProto_success() throws GrpcServiceParseException { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).addCallCredentialsPlugin(accessTokenCreds) + .build(); + HeaderValue asciiHeader = + HeaderValue.newBuilder().setKey("test_key").setValue("test_value").build(); + HeaderValue binaryHeader = HeaderValue.newBuilder().setKey("test_key-bin") + .setValue( + BaseEncoding.base64().encode("test_value_binary".getBytes(StandardCharsets.UTF_8))) + .build(); + Duration timeout = Duration.newBuilder().setSeconds(10).build(); + GrpcService grpcService = + GrpcService.newBuilder().setGoogleGrpc(googleGrpc).addInitialMetadata(asciiHeader) + .addInitialMetadata(binaryHeader).setTimeout(timeout).build(); + + GrpcServiceConfig config = GrpcServiceConfig.fromProto(grpcService); + + // Assert target URI + assertThat(config.googleGrpc().target()).isEqualTo("test_uri"); + + // Assert channel credentials + assertThat(config.googleGrpc().hashedChannelCredentials().channelCredentials()) + .isInstanceOf(InsecureChannelCredentials.class); + assertThat(config.googleGrpc().hashedChannelCredentials().hash()) + .isEqualTo(insecureCreds.hashCode()); + + // Assert call credentials + assertThat(config.googleGrpc().callCredentials().getClass().getName()) + .isEqualTo("io.grpc.auth.GoogleAuthLibraryCallCredentials"); + + // Assert initial metadata + assertThat(config.initialMetadata().isPresent()).isTrue(); + assertThat(config.initialMetadata().get() + .get(Metadata.Key.of("test_key", Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("test_value"); + assertThat(config.initialMetadata().get() + .get(Metadata.Key.of("test_key-bin", Metadata.BINARY_BYTE_MARSHALLER))) + .isEqualTo("test_value_binary".getBytes(StandardCharsets.UTF_8)); + + // Assert timeout + assertThat(config.timeout().isPresent()).isTrue(); + assertThat(config.timeout().get()).isEqualTo(java.time.Duration.ofSeconds(10)); + } + + @Test + public void fromProto_minimalSuccess_defaults() throws GrpcServiceParseException { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).addCallCredentialsPlugin(accessTokenCreds) + .build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceConfig config = GrpcServiceConfig.fromProto(grpcService); + + assertThat(config.googleGrpc().target()).isEqualTo("test_uri"); + assertThat(config.initialMetadata().isPresent()).isFalse(); + assertThat(config.timeout().isPresent()).isFalse(); + } + + @Test + public void fromProto_missingGoogleGrpc() { + GrpcService grpcService = GrpcService.newBuilder().build(); + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> GrpcServiceConfig.fromProto(grpcService)); + assertThat(exception).hasMessageThat() + .startsWith("Unsupported: GrpcService must have GoogleGrpc, got: "); + } + + @Test + public void fromProto_emptyCallCredentials() { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> GrpcServiceConfig.fromProto(grpcService)); + assertThat(exception).hasMessageThat() + .isEqualTo("No valid supported call_credentials found. Errors: []"); + } + + @Test + public void fromProto_emptyChannelCredentials() { + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addCallCredentialsPlugin(accessTokenCreds).build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> GrpcServiceConfig.fromProto(grpcService)); + assertThat(exception).hasMessageThat() + .isEqualTo("No valid supported channel_credentials found. Errors: []"); + } + + @Test + public void fromProto_googleDefaultCredentials() throws GrpcServiceParseException { + Any googleDefaultCreds = Any.pack(GoogleDefaultCredentials.getDefaultInstance()); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(googleDefaultCreds).addCallCredentialsPlugin(accessTokenCreds) + .build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceConfig config = GrpcServiceConfig.fromProto(grpcService); + + assertThat(config.googleGrpc().hashedChannelCredentials().channelCredentials()) + .isInstanceOf(io.grpc.CompositeChannelCredentials.class); + assertThat(config.googleGrpc().hashedChannelCredentials().hash()) + .isEqualTo(googleDefaultCreds.hashCode()); + } + + @Test + public void fromProto_localCredentials() throws GrpcServiceParseException { + Any localCreds = Any.pack(LocalCredentials.getDefaultInstance()); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(localCreds).addCallCredentialsPlugin(accessTokenCreds).build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> GrpcServiceConfig.fromProto(grpcService)); + assertThat(exception).hasMessageThat().contains("LocalCredentials are not yet supported."); + } + + @Test + public void fromProto_xdsCredentials_withInsecureFallback() throws GrpcServiceParseException { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + XdsCredentials xdsCreds = + XdsCredentials.newBuilder().setFallbackCredentials(insecureCreds).build(); + Any xdsCredsAny = Any.pack(xdsCreds); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(xdsCredsAny).addCallCredentialsPlugin(accessTokenCreds) + .build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceConfig config = GrpcServiceConfig.fromProto(grpcService); + + assertThat(config.googleGrpc().hashedChannelCredentials().channelCredentials()) + .isInstanceOf(io.grpc.ChannelCredentials.class); + assertThat(config.googleGrpc().hashedChannelCredentials().hash()) + .isEqualTo(xdsCredsAny.hashCode()); + } + + @Test + public void fromProto_tlsCredentials_notSupported() { + Any tlsCreds = Any + .pack(io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.tls.v3.TlsCredentials + .getDefaultInstance()); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(tlsCreds).addCallCredentialsPlugin(accessTokenCreds).build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> GrpcServiceConfig.fromProto(grpcService)); + assertThat(exception).hasMessageThat().contains("TlsCredentials are not yet supported."); + } + + @Test + public void fromProto_invalidChannelCredentialsProto() { + // Pack a Duration proto, but try to unpack it as GoogleDefaultCredentials + Any invalidCreds = Any.pack(com.google.protobuf.Duration.getDefaultInstance()); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(invalidCreds).addCallCredentialsPlugin(accessTokenCreds) + .build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> GrpcServiceConfig.fromProto(grpcService)); + assertThat(exception).hasMessageThat() + .contains("No valid supported channel_credentials found. Errors: [Unsupported channel " + + "credentials type: type.googleapis.com/google.protobuf.Duration"); + } + + @Test + public void fromProto_invalidCallCredentialsProto() { + // Pack a Duration proto, but try to unpack it as AccessTokenCredentials + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + Any invalidCallCredentials = Any.pack(Duration.getDefaultInstance()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).addCallCredentialsPlugin(invalidCallCredentials) + .build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> GrpcServiceConfig.fromProto(grpcService)); + assertThat(exception).hasMessageThat().contains("Unsupported call credentials type:"); + } +} + diff --git a/xds/src/test/java/io/grpc/xds/internal/grpcservice/InsecureGrpcChannelFactoryTest.java b/xds/src/test/java/io/grpc/xds/internal/grpcservice/InsecureGrpcChannelFactoryTest.java new file mode 100644 index 00000000000..8d7347f56c6 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/grpcservice/InsecureGrpcChannelFactoryTest.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.grpcservice; + +import static org.junit.Assert.assertNotNull; + +import io.grpc.CallCredentials; +import io.grpc.InsecureChannelCredentials; +import io.grpc.ManagedChannel; +import io.grpc.Metadata; +import io.grpc.xds.internal.grpcservice.GrpcServiceConfig.GoogleGrpcConfig; +import io.grpc.xds.internal.grpcservice.GrpcServiceConfig.HashedChannelCredentials; +import java.util.concurrent.Executor; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for {@link InsecureGrpcChannelFactory}. */ +@RunWith(JUnit4.class) +public class InsecureGrpcChannelFactoryTest { + + private static final class NoOpCallCredentials extends CallCredentials { + @Override + public void applyRequestMetadata(RequestInfo requestInfo, Executor appExecutor, + MetadataApplier applier) { + applier.apply(new Metadata()); + } + } + + @Test + public void testCreateChannel() { + InsecureGrpcChannelFactory factory = InsecureGrpcChannelFactory.getInstance(); + GrpcServiceConfig config = GrpcServiceConfig.builder() + .googleGrpc(GoogleGrpcConfig.builder().target("localhost:8080") + .hashedChannelCredentials( + HashedChannelCredentials.of(InsecureChannelCredentials.create(), 0)) + .callCredentials(new NoOpCallCredentials()).build()) + .build(); + ManagedChannel channel = factory.createChannel(config); + assertNotNull(channel); + channel.shutdownNow(); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfigTest.java b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfigTest.java new file mode 100644 index 00000000000..e2bda9cb836 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfigTest.java @@ -0,0 +1,84 @@ +/* + * 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.headermutations; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.regex.Pattern; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class HeaderMutationRulesConfigTest { + @Test + public void testBuilderDefaultValues() { + HeaderMutationRulesConfig config = HeaderMutationRulesConfig.builder().build(); + assertFalse(config.disallowAll()); + assertFalse(config.disallowIsError()); + assertThat(config.allowExpression()).isEmpty(); + assertThat(config.disallowExpression()).isEmpty(); + } + + @Test + public void testBuilder_setDisallowAll() { + HeaderMutationRulesConfig config = + HeaderMutationRulesConfig.builder().disallowAll(true).build(); + assertTrue(config.disallowAll()); + } + + @Test + public void testBuilder_setDisallowIsError() { + HeaderMutationRulesConfig config = + HeaderMutationRulesConfig.builder().disallowIsError(true).build(); + assertTrue(config.disallowIsError()); + } + + @Test + public void testBuilder_setAllowExpression() { + Pattern pattern = Pattern.compile("allow.*"); + HeaderMutationRulesConfig config = + HeaderMutationRulesConfig.builder().allowExpression(pattern).build(); + assertThat(config.allowExpression()).hasValue(pattern); + } + + @Test + public void testBuilder_setDisallowExpression() { + Pattern pattern = Pattern.compile("disallow.*"); + HeaderMutationRulesConfig config = + HeaderMutationRulesConfig.builder().disallowExpression(pattern).build(); + assertThat(config.disallowExpression()).hasValue(pattern); + } + + @Test + public void testBuilder_setAll() { + Pattern allowPattern = Pattern.compile("allow.*"); + Pattern disallowPattern = Pattern.compile("disallow.*"); + HeaderMutationRulesConfig config = HeaderMutationRulesConfig.builder() + .disallowAll(true) + .disallowIsError(true) + .allowExpression(allowPattern) + .disallowExpression(disallowPattern) + .build(); + assertTrue(config.disallowAll()); + assertTrue(config.disallowIsError()); + assertThat(config.allowExpression()).hasValue(allowPattern); + assertThat(config.disallowExpression()).hasValue(disallowPattern); + } +} From 99f9b3ae1a85361d2e68dc5b38ebad57d7229db8 Mon Sep 17 00:00:00 2001 From: Saurav Date: Mon, 10 Nov 2025 21:52:52 +0000 Subject: [PATCH 3/8] feat(xds): Implement request builder for external authorization This commit introduces the `CheckRequestBuilder` library, which is responsible for constructing the `CheckRequest` message sent to the external authorization service. The `CheckRequestBuilder` gathers information from various sources, including: - `ServerCall` attributes (local and remote addresses, SSL session). - `MethodDescriptor` (full method name). - Request headers. It uses this information to populate the `AttributeContext` of the `CheckRequest` message, which provides the authorization service with the necessary context to make an authorization decision. This commit also introduces the `ExtAuthzCertificateProvider`, a helper class for extracting certificate information, such as the principal and PEM-encoded certificate. Unit tests for the new components are also included. --- .../extauthz/CheckRequestBuilder.java | 316 ++++++++++++++++ .../extauthz/ExtAuthzCertificateProvider.java | 132 +++++++ .../extauthz/CheckRequestBuilderTest.java | 350 ++++++++++++++++++ .../ExtAuthzCertificateProviderTest.java | 140 +++++++ 4 files changed, 938 insertions(+) create mode 100644 xds/src/main/java/io/grpc/xds/internal/extauthz/CheckRequestBuilder.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzCertificateProvider.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/extauthz/CheckRequestBuilderTest.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzCertificateProviderTest.java diff --git a/xds/src/main/java/io/grpc/xds/internal/extauthz/CheckRequestBuilder.java b/xds/src/main/java/io/grpc/xds/internal/extauthz/CheckRequestBuilder.java new file mode 100644 index 00000000000..55234cd50dc --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extauthz/CheckRequestBuilder.java @@ -0,0 +1,316 @@ +/* + * 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.io.BaseEncoding; +import com.google.protobuf.Timestamp; +import io.envoyproxy.envoy.config.core.v3.Address; +import io.envoyproxy.envoy.config.core.v3.SocketAddress; +import io.envoyproxy.envoy.service.auth.v3.AttributeContext; +import io.envoyproxy.envoy.service.auth.v3.CheckRequest; +import io.grpc.Grpc; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.ServerCall; +import io.grpc.xds.internal.Matchers; +import java.io.UnsupportedEncodingException; +import java.net.InetSocketAddress; +import java.security.cert.Certificate; +import java.security.cert.CertificateEncodingException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSession; + +/** + * Interface for building external authorization check requests. + */ +public interface CheckRequestBuilder { + + /** + * A factory for creating {@link CheckRequestBuilder} instances. + */ + @FunctionalInterface + interface Factory { + /** + * Creates a new instance of the CheckRequestBuilder. + * + * @param config The external authorization configuration. + * @param certificateProvider The provider for certificate information. + * @return A new CheckRequestBuilder instance. + */ + CheckRequestBuilder create(ExtAuthzConfig config, + ExtAuthzCertificateProvider certificateProvider); + } + + /** The default factory for creating {@link CheckRequestBuilder} instances. */ + Factory INSTANCE = CheckRequestBuilderImpl::new; + + /** + * Builds a CheckRequest for a server-side call. + * + * @param serverCall The server call. + * @param headers The request headers. + * @param requestTime The time of the request. + * @return A new CheckRequest. + */ + CheckRequest buildRequest(ServerCall serverCall, Metadata headers, Timestamp requestTime); + + /** + * Builds a CheckRequest for a client-side call. + * + * @param methodDescriptor The method descriptor of the call. + * @param headers The request headers. + * @param requestTime The time of the request. + * @return A new CheckRequest. + */ + CheckRequest buildRequest(MethodDescriptor methodDescriptor, Metadata headers, + Timestamp requestTime); + + /** + * Implementation of the CheckRequestBuilder interface. + */ + final class CheckRequestBuilderImpl implements CheckRequestBuilder { + private static final Logger logger = Logger.getLogger(CheckRequestBuilderImpl.class.getName()); + + private static final String METHOD = "POST"; + private static final String PROTOCOL = "HTTP/2"; + private static final long SIZE = -1; + + private final ExtAuthzConfig config; + private final ExtAuthzCertificateProvider certificateProvider; + + CheckRequestBuilderImpl(ExtAuthzConfig config, + ExtAuthzCertificateProvider certificateProvider) { + this.config = config; + this.certificateProvider = certificateProvider; + } + + @Override + public CheckRequest buildRequest(MethodDescriptor methodDescriptor, Metadata headers, + Timestamp requestTime) { + return build(CheckRequestParams.builder().setMethodDescriptor(methodDescriptor) + .setHeaders(headers).setRequestTime(requestTime).build()); + } + + @Override + public CheckRequest buildRequest(ServerCall serverCall, Metadata headers, + Timestamp requestTime) { + CheckRequestParams.Builder paramsBuilder = + CheckRequestParams.builder().setMethodDescriptor(serverCall.getMethodDescriptor()) + .setHeaders(headers).setRequestTime(requestTime); + java.net.SocketAddress localAddress = + serverCall.getAttributes().get(Grpc.TRANSPORT_ATTR_LOCAL_ADDR); + if (localAddress != null) { + paramsBuilder.setLocalAddress(localAddress); + } + java.net.SocketAddress remoteAddress = + serverCall.getAttributes().get(Grpc.TRANSPORT_ATTR_REMOTE_ADDR); + if (remoteAddress != null) { + paramsBuilder.setRemoteAddress(remoteAddress); + } + SSLSession sslSession = serverCall.getAttributes().get(Grpc.TRANSPORT_ATTR_SSL_SESSION); + if (sslSession != null) { + paramsBuilder.setSslSession(sslSession); + } + return build(paramsBuilder.build()); + } + + private CheckRequest build(CheckRequestParams params) { + AttributeContext.Builder attrBuilder = AttributeContext.newBuilder(); + if (params.remoteAddress().isPresent()) { + attrBuilder.setSource(buildSource(params.remoteAddress().get(), params.sslSession())); + } + if (params.localAddress().isPresent()) { + attrBuilder + .setDestination(buildDestination(params.localAddress().get(), params.sslSession())); + } + attrBuilder.setRequest(buildAttributeRequest(params.headers(), + params.methodDescriptor().getFullMethodName(), params.requestTime())); + return CheckRequest.newBuilder().setAttributes(attrBuilder).build(); + } + + private AttributeContext.Peer buildSource(java.net.SocketAddress socketAddress, + Optional sslSession) { + AttributeContext.Peer.Builder peerBuilder = buildPeer(socketAddress).toBuilder(); + if (sslSession.isPresent()) { + try { + Certificate[] certs = sslSession.get().getPeerCertificates(); + if (certs != null && certs.length > 0 && certs[0] instanceof X509Certificate) { + X509Certificate cert = (X509Certificate) certs[0]; + peerBuilder.setPrincipal(certificateProvider.getPrincipal(cert)); + if (config.includePeerCertificate()) { + try { + peerBuilder.setCertificate(certificateProvider.getUrlPemEncodedCertificate(cert)); + } catch (UnsupportedEncodingException | CertificateEncodingException e) { + logger.log(Level.WARNING, + "Error encoding peer certificate. " + + "This is not expected, but if it happens, the certificate should not " + + "be set according to the spec.", + e); + } + } + } + } catch (SSLPeerUnverifiedException e) { + logger.log(Level.FINE, + "Peer is not authenticated. " + + "This is expected, principal and certificate should not be set " + + "according to the spec.", + e); + } + } + return peerBuilder.build(); + } + + private AttributeContext.Peer buildDestination(java.net.SocketAddress socketAddress, + Optional sslSession) { + AttributeContext.Peer.Builder peerBuilder = buildPeer(socketAddress).toBuilder(); + if (sslSession.isPresent()) { + Certificate[] certs = sslSession.get().getLocalCertificates(); + if (certs != null && certs.length > 0 && certs[0] instanceof X509Certificate) { + peerBuilder.setPrincipal(certificateProvider.getPrincipal((X509Certificate) certs[0])); + } + } + return peerBuilder.build(); + } + + private AttributeContext.Peer buildPeer(java.net.SocketAddress socketAddress) { + AttributeContext.Peer.Builder peerBuilder = AttributeContext.Peer.newBuilder(); + if (socketAddress instanceof InetSocketAddress) { + InetSocketAddress inetSocketAddress = (InetSocketAddress) socketAddress; + peerBuilder.setAddress(Address.newBuilder() + .setSocketAddress(SocketAddress.newBuilder() + .setAddress(inetSocketAddress.getAddress().getHostAddress()) + .setPortValue(inetSocketAddress.getPort())) + .build()); + } + return peerBuilder.build(); + } + + private AttributeContext.Request buildAttributeRequest(Metadata headers, String fullMethodName, + Timestamp requestTime) { + AttributeContext.Request.Builder reqBuilder = AttributeContext.Request.newBuilder(); + reqBuilder.setTime(requestTime); + AttributeContext.HttpRequest.Builder httpReqBuilder = + AttributeContext.HttpRequest.newBuilder(); + httpReqBuilder.setPath(fullMethodName); + httpReqBuilder.setMethod(METHOD); + httpReqBuilder.setProtocol(PROTOCOL); + httpReqBuilder.setSize(SIZE); + for (String key : headers.keys()) { + if (!isAllowed(key)) { + continue; + } + Optional value; + if (key.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + value = getBinaryHeaderValue(headers, key); + } else { + value = getAsciiHeaderValue(headers, key); + } + value.ifPresent( + headerValue -> httpReqBuilder.putHeaders(key.toLowerCase(Locale.ROOT), headerValue)); + } + reqBuilder.setHttp(httpReqBuilder); + return reqBuilder.build(); + } + + private Optional getBinaryHeaderValue(Metadata headers, String key) { + Iterable binaryValues = + headers.getAll(Metadata.Key.of(key, Metadata.BINARY_BYTE_MARSHALLER)); + if (binaryValues == null) { + // Unreachable code, since we iterate over the keys. Exists for defensive programming. + return Optional.empty(); + } + List base64Values = new ArrayList<>(); + for (byte[] value : binaryValues) { + base64Values.add(BaseEncoding.base64().encode(value)); + } + return Optional.of(String.join(",", base64Values)); + } + + private Optional getAsciiHeaderValue(Metadata headers, String key) { + Iterable stringValues = + headers.getAll(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER)); + if (stringValues == null) { + // Unreachable code, since we iterate over the keys. Exists for defensive programming. + return Optional.empty(); + } + return Optional.of(String.join(",", stringValues)); + } + + private boolean isAllowed(String header) { + for (Matchers.StringMatcher matcher : config.disallowedHeaders()) { + if (matcher.matches(header)) { + return false; + } + } + if (config.allowedHeaders().isEmpty()) { + return true; + } + for (Matchers.StringMatcher matcher : config.allowedHeaders()) { + if (matcher.matches(header)) { + return true; + } + } + return false; + } + + @AutoValue + abstract static class CheckRequestParams { + abstract Metadata headers(); + + abstract MethodDescriptor methodDescriptor(); + + abstract Timestamp requestTime(); + + abstract Optional localAddress(); + + abstract Optional remoteAddress(); + + abstract Optional sslSession(); + + static Builder builder() { + Builder builder = + new AutoValue_CheckRequestBuilder_CheckRequestBuilderImpl_CheckRequestParams.Builder(); + return builder; + } + + @AutoValue.Builder + abstract static class Builder { + abstract Builder setHeaders(Metadata headers); + + abstract Builder setMethodDescriptor(MethodDescriptor method); + + abstract Builder setRequestTime(Timestamp time); + + abstract Builder setLocalAddress(java.net.SocketAddress localAddress); + + abstract Builder setRemoteAddress(java.net.SocketAddress remoteAddress); + + abstract Builder setSslSession(SSLSession sslSession); + + abstract CheckRequestParams build(); + } + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzCertificateProvider.java b/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzCertificateProvider.java new file mode 100644 index 00000000000..b4ec8dd8303 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzCertificateProvider.java @@ -0,0 +1,132 @@ +/* + * 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.io.BaseEncoding; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.cert.CertificateEncodingException; +import java.security.cert.X509Certificate; +import java.util.Collection; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * An interface for providing certificate-related information. + */ +public interface ExtAuthzCertificateProvider { + /** + * Creates a new instance of the CertificateProvider. + * + * @return A new CertificateProvider instance. + */ + static ExtAuthzCertificateProvider create() { + return new DefaultCertificateProvider(); + } + + /** + * Gets the principal from a certificate. It returns the cert's first IP Address SAN if set, + * otherwise the cert's first DNS SAN if set, otherwise the subject field of the certificate in + * RFC 2253 format. + * + * @param cert The certificate. + * @return The principal. + */ + String getPrincipal(X509Certificate cert); + + /** + * Gets the URL PEM encoded certificate. It Pem encodes first and then urlencodes. + * + * @param cert The certificate. + * @return The URL PEM encoded certificate. + * @throws CertificateEncodingException If an error occurs while encoding the certificate. + * @throws UnsupportedEncodingException If an error occurs while encoding the URL. + */ + String getUrlPemEncodedCertificate(X509Certificate cert) + throws CertificateEncodingException, UnsupportedEncodingException; + + /** + * Default implementation of the CertificateProvider interface. + */ + final class DefaultCertificateProvider implements ExtAuthzCertificateProvider { + private static final Logger logger = + Logger.getLogger(DefaultCertificateProvider.class.getName()); + // From RFC 5280, section 4.2.1.6, Subject Alternative Name + // dNSName (2) + // iPAddress (7) + private static final int SAN_TYPE_DNS_NAME = 2; + private static final int SAN_TYPE_IP_ADDRESS = 7; + + @Override + public String getPrincipal(X509Certificate cert) { + try { + Collection> sans = cert.getSubjectAlternativeNames(); + if (sans != null) { + // Look for IP Address SAN. + for (List san : sans) { + if (san.size() == 2 && san.get(0) instanceof Integer + && (Integer) san.get(0) == SAN_TYPE_IP_ADDRESS) { + return (String) san.get(1); + } + } + // If no IP Address SAN, look for DNS SAN. + for (List san : sans) { + if (san.size() == 2 && san.get(0) instanceof Integer + && (Integer) san.get(0) == SAN_TYPE_DNS_NAME) { + return (String) san.get(1); + } + } + } + } catch (java.security.cert.CertificateParsingException e) { + logger.log(Level.WARNING, "Error parsing certificate SANs. " + "This is not expected," + + "falling back to the subject according to the spec.", e); + } + return cert.getSubjectX500Principal().getName(); + } + + @Override + public String getUrlPemEncodedCertificate(X509Certificate cert) + throws CertificateEncodingException, UnsupportedEncodingException { + String pemCert = CertPemConverter.toPem(cert); + return URLEncoder.encode(pemCert, StandardCharsets.UTF_8.toString()); + } + } + + /** + * A utility class for PEM encoding. + */ + final class CertPemConverter { + + private static final String X509_PEM_HEADER = "-----BEGIN CERTIFICATE-----\n"; + private static final String X509_PEM_FOOTER = "\n-----END CERTIFICATE-----\n"; + + private CertPemConverter() {} + + /** + * Converts a certificate to a PEM string. + * + * @param cert The certificate to convert. + * @return The PEM encoded certificate. + * @throws CertificateEncodingException If an error occurs while encoding the certificate. + */ + public static String toPem(X509Certificate cert) throws CertificateEncodingException { + return X509_PEM_HEADER + BaseEncoding.base64().encode(cert.getEncoded()) + X509_PEM_FOOTER; + } + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/extauthz/CheckRequestBuilderTest.java b/xds/src/test/java/io/grpc/xds/internal/extauthz/CheckRequestBuilderTest.java new file mode 100644 index 00000000000..1faa0062a04 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/extauthz/CheckRequestBuilderTest.java @@ -0,0 +1,350 @@ +/* + * 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.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.protobuf.Any; +import com.google.protobuf.Timestamp; +import io.envoyproxy.envoy.config.core.v3.Address; +import io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz; +import io.envoyproxy.envoy.extensions.grpc_service.call_credentials.access_token.v3.AccessTokenCredentials; +import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.google_default.v3.GoogleDefaultCredentials; +import io.envoyproxy.envoy.service.auth.v3.AttributeContext; +import io.envoyproxy.envoy.service.auth.v3.CheckRequest; +import io.envoyproxy.envoy.type.matcher.v3.ListStringMatcher; +import io.envoyproxy.envoy.type.matcher.v3.StringMatcher; +import io.grpc.Attributes; +import io.grpc.Grpc; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.ServerCall; +import io.grpc.testing.TestMethodDescriptors; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSession; +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; + +@RunWith(JUnit4.class) +public class CheckRequestBuilderTest { + @Rule + public final MockitoRule mockito = MockitoJUnit.rule(); + + @Mock + private ServerCall serverCall; + @Mock + private SSLSession sslSession; + @Mock + private ExtAuthzCertificateProvider certificateProvider; + + private CheckRequestBuilder checkRequestBuilder; + private MethodDescriptor methodDescriptor; + private Timestamp requestTime; + + @Before + public void setUp() throws ExtAuthzParseException { + ExtAuthzConfig config = buildExtAuthzConfig(); + checkRequestBuilder = CheckRequestBuilder.INSTANCE.create(config, certificateProvider); + methodDescriptor = TestMethodDescriptors.voidMethod(); + requestTime = Timestamp.newBuilder().setSeconds(12345).setNanos(67890).build(); + } + + @Test + public void buildRequest_forServer_happyPath() throws Exception { + // Setup for addresses + SocketAddress localAddress = new InetSocketAddress("10.0.0.2", 443); + SocketAddress remoteAddress = new InetSocketAddress("192.168.1.1", 12345); + + // Setup for SSL and certificates + X509Certificate peerCert = mock(X509Certificate.class); + X509Certificate localCert = mock(X509Certificate.class); + Certificate[] peerCerts = new Certificate[] {peerCert}; + Certificate[] localCerts = new Certificate[] {localCert}; + when(sslSession.getPeerCertificates()).thenReturn(peerCerts); + when(sslSession.getLocalCertificates()).thenReturn(localCerts); + when(certificateProvider.getPrincipal(peerCert)).thenReturn("peer-principal"); + when(certificateProvider.getPrincipal(localCert)).thenReturn("local-principal"); + when(certificateProvider.getUrlPemEncodedCertificate(peerCert)).thenReturn("encoded-peer-cert"); + + // Setup for headers + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("allowed-header", Metadata.ASCII_STRING_MARSHALLER), "v1"); + headers.put(Metadata.Key.of("disallowed-header", Metadata.ASCII_STRING_MARSHALLER), "v2"); + headers.put(Metadata.Key.of("overridden-header", Metadata.ASCII_STRING_MARSHALLER), "v3"); + byte[] binaryValue = new byte[] {1, 2, 3}; + headers.put(Metadata.Key.of("bin-header-bin", Metadata.BINARY_BYTE_MARSHALLER), binaryValue); + + // Configure CheckRequestBuilder to allow specific headers + ListStringMatcher allowedHeaders = ListStringMatcher.newBuilder() + .addPatterns(StringMatcher.newBuilder().setExact("allowed-header").build()) + .addPatterns(StringMatcher.newBuilder().setExact("overridden-header").build()).build(); + ListStringMatcher disallowedHeaders = ListStringMatcher.newBuilder() + .addPatterns(StringMatcher.newBuilder().setExact("disallowed-header").build()) + .addPatterns(StringMatcher.newBuilder().setExact("overridden-header").build()).build(); + ExtAuthzConfig config = buildExtAuthzConfig(allowedHeaders, disallowedHeaders, true); + checkRequestBuilder = CheckRequestBuilder.INSTANCE.create(config, certificateProvider); + + // Setup server call attributes + Attributes attributes = + Attributes.newBuilder().set(Grpc.TRANSPORT_ATTR_LOCAL_ADDR, localAddress) + .set(Grpc.TRANSPORT_ATTR_REMOTE_ADDR, remoteAddress) + .set(Grpc.TRANSPORT_ATTR_SSL_SESSION, sslSession).build(); + when(serverCall.getAttributes()).thenReturn(attributes); + when(serverCall.getMethodDescriptor()).thenReturn(methodDescriptor); + + // Build and verify the request + CheckRequest request = checkRequestBuilder.buildRequest(serverCall, headers, requestTime); + + AttributeContext attrContext = request.getAttributes(); + assertThat(attrContext.getSource().getAddress().getSocketAddress().getAddress()) + .isEqualTo("192.168.1.1"); + assertThat(attrContext.getSource().getPrincipal()).isEqualTo("peer-principal"); + assertThat(attrContext.getSource().getCertificate()).isEqualTo("encoded-peer-cert"); + assertThat(attrContext.getDestination().getAddress().getSocketAddress().getAddress()) + .isEqualTo("10.0.0.2"); + assertThat(attrContext.getDestination().getPrincipal()).isEqualTo("local-principal"); + + AttributeContext.HttpRequest http = attrContext.getRequest().getHttp(); + assertThat(http.getHeadersMap()).containsEntry("allowed-header", "v1"); + assertThat(http.getHeadersMap()).doesNotContainKey("bin-header-bin"); + assertThat(http.getHeadersMap()).doesNotContainKey("disallowed-header"); + assertThat(http.getHeadersMap()).doesNotContainKey("overridden-header"); + } + + @Test + public void buildRequest_forServer_noTransportAttrs() { + when(serverCall.getAttributes()).thenReturn(Attributes.EMPTY); + when(serverCall.getMethodDescriptor()).thenReturn(methodDescriptor); + Metadata headers = new Metadata(); + + CheckRequest request = checkRequestBuilder.buildRequest(serverCall, headers, requestTime); + + assertThat(request.getAttributes().getRequest().getTime()).isEqualTo(requestTime); + assertThat(request.getAttributes().getRequest().getHttp().getPath()) + .isEqualTo(methodDescriptor.getFullMethodName()); + assertThat(request.getAttributes().getRequest().getHttp().getMethod()).isEqualTo("POST"); + assertThat(request.getAttributes().getRequest().getHttp().getProtocol()).isEqualTo("HTTP/2"); + assertThat(request.getAttributes().getRequest().getHttp().getSize()).isEqualTo(-1); + assertThat(request.getAttributes().getRequest().getHttp().getHeadersMap()).isEmpty(); + assertThat(request.getAttributes().hasSource()).isFalse(); + assertThat(request.getAttributes().hasDestination()).isFalse(); + } + + + @Test + public void buildRequest_forClient_happyPath_emptyAllowedHeaders() throws Exception { + // Setup for headers + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("some-header", Metadata.ASCII_STRING_MARSHALLER), "v1"); + headers.put(Metadata.Key.of("disallowed-header", Metadata.ASCII_STRING_MARSHALLER), "v2"); + byte[] binaryValue = new byte[] {1, 2, 3}; + headers.put(Metadata.Key.of("bin-header-bin", Metadata.BINARY_BYTE_MARSHALLER), binaryValue); + + // Configure CheckRequestBuilder with empty allowed headers + ListStringMatcher allowedHeaders = ListStringMatcher.newBuilder().build(); // empty + ListStringMatcher disallowedHeaders = ListStringMatcher.newBuilder() + .addPatterns(StringMatcher.newBuilder().setExact("disallowed-header").build()).build(); + ExtAuthzConfig config = buildExtAuthzConfig(allowedHeaders, disallowedHeaders, true); + checkRequestBuilder = CheckRequestBuilder.INSTANCE.create(config, certificateProvider); + + // Build and verify the request + CheckRequest request = checkRequestBuilder.buildRequest(methodDescriptor, headers, requestTime); + + AttributeContext attrContext = request.getAttributes(); + assertThat(attrContext.hasSource()).isFalse(); + assertThat(attrContext.hasDestination()).isFalse(); + + AttributeContext.HttpRequest http = attrContext.getRequest().getHttp(); + assertThat(http.getPath()).isEqualTo(methodDescriptor.getFullMethodName()); + assertThat(http.getHeadersMap()).containsEntry("some-header", "v1"); + assertThat(http.getHeadersMap()).containsEntry("bin-header-bin", "AQID"); + assertThat(http.getHeadersMap()).doesNotContainKey("disallowed-header"); + } + + @Test + public void buildRequest_forServer_noSslSession() { + SocketAddress localAddress = new InetSocketAddress("10.0.0.2", 443); + SocketAddress remoteAddress = new InetSocketAddress("192.168.1.1", 12345); + Attributes attributes = + Attributes.newBuilder().set(Grpc.TRANSPORT_ATTR_LOCAL_ADDR, localAddress) + .set(Grpc.TRANSPORT_ATTR_REMOTE_ADDR, remoteAddress).build(); + when(serverCall.getAttributes()).thenReturn(attributes); + when(serverCall.getMethodDescriptor()).thenReturn(methodDescriptor); + + CheckRequest request = + checkRequestBuilder.buildRequest(serverCall, new Metadata(), requestTime); + + AttributeContext attrContext = request.getAttributes(); + assertThat(attrContext.hasSource()).isTrue(); + Address sourceAddress = attrContext.getSource().getAddress(); + assertThat(sourceAddress.getSocketAddress().getAddress()).isEqualTo("192.168.1.1"); + assertThat(sourceAddress.getSocketAddress().getPortValue()).isEqualTo(12345); + assertThat(attrContext.getSource().getPrincipal()).isEmpty(); + + assertThat(attrContext.hasDestination()).isTrue(); + Address destAddress = attrContext.getDestination().getAddress(); + assertThat(destAddress.getSocketAddress().getAddress()).isEqualTo("10.0.0.2"); + assertThat(destAddress.getSocketAddress().getPortValue()).isEqualTo(443); + assertThat(attrContext.getDestination().getPrincipal()).isEmpty(); + } + + @Test + public void buildRequest_forServer_sslPeerUnverified() throws Exception { + SocketAddress remoteAddress = new InetSocketAddress("192.168.1.1", 12345); + when(sslSession.getPeerCertificates()).thenThrow(new SSLPeerUnverifiedException("unverified")); + Attributes attributes = + Attributes.newBuilder().set(Grpc.TRANSPORT_ATTR_REMOTE_ADDR, remoteAddress) + .set(Grpc.TRANSPORT_ATTR_SSL_SESSION, sslSession).build(); + when(serverCall.getAttributes()).thenReturn(attributes); + when(serverCall.getMethodDescriptor()).thenReturn(methodDescriptor); + + CheckRequest request = + checkRequestBuilder.buildRequest(serverCall, new Metadata(), requestTime); + + AttributeContext.Peer source = request.getAttributes().getSource(); + assertThat(source.getPrincipal()).isEmpty(); + assertThat(source.getCertificate()).isEmpty(); + } + + @Test + public void buildRequest_forServer_includePeerCertFalse() throws Exception { + ExtAuthzConfig config = buildExtAuthzConfig(ListStringMatcher.newBuilder().build(), + ListStringMatcher.newBuilder().build(), false); + checkRequestBuilder = CheckRequestBuilder.INSTANCE.create(config, certificateProvider); + SocketAddress remoteAddress = new InetSocketAddress("192.168.1.1", 12345); + X509Certificate peerCert = mock(X509Certificate.class); + Certificate[] peerCerts = new Certificate[] {peerCert}; + + when(sslSession.getPeerCertificates()).thenReturn(peerCerts); + when(certificateProvider.getPrincipal(peerCert)).thenReturn("peer-principal"); + + Attributes attributes = + Attributes.newBuilder().set(Grpc.TRANSPORT_ATTR_REMOTE_ADDR, remoteAddress) + .set(Grpc.TRANSPORT_ATTR_SSL_SESSION, sslSession).build(); + when(serverCall.getAttributes()).thenReturn(attributes); + when(serverCall.getMethodDescriptor()).thenReturn(methodDescriptor); + + CheckRequest request = + checkRequestBuilder.buildRequest(serverCall, new Metadata(), requestTime); + + AttributeContext.Peer source = request.getAttributes().getSource(); + assertThat(source.getPrincipal()).isEqualTo("peer-principal"); + assertThat(source.getCertificate()).isEmpty(); + } + + @Test + public void buildRequest_forServer_nullOrEmptyCertificates() throws Exception { + SocketAddress localAddress = new InetSocketAddress("10.0.0.2", 443); + SocketAddress remoteAddress = new InetSocketAddress("192.168.1.1", 12345); + Attributes attributes = + Attributes.newBuilder().set(Grpc.TRANSPORT_ATTR_LOCAL_ADDR, localAddress) + .set(Grpc.TRANSPORT_ATTR_REMOTE_ADDR, remoteAddress) + .set(Grpc.TRANSPORT_ATTR_SSL_SESSION, sslSession).build(); + when(serverCall.getAttributes()).thenReturn(attributes); + when(serverCall.getMethodDescriptor()).thenReturn(methodDescriptor); + + // Test with null certificates + when(sslSession.getPeerCertificates()).thenReturn(null); + when(sslSession.getLocalCertificates()).thenReturn(null); + CheckRequest request = + checkRequestBuilder.buildRequest(serverCall, new Metadata(), requestTime); + AttributeContext.Peer source = request.getAttributes().getSource(); + assertThat(source.getPrincipal()).isEmpty(); + assertThat(source.getCertificate()).isEmpty(); + AttributeContext.Peer destination = request.getAttributes().getDestination(); + assertThat(destination.getPrincipal()).isEmpty(); + + // Test with empty certificates + when(sslSession.getPeerCertificates()).thenReturn(new Certificate[0]); + when(sslSession.getLocalCertificates()).thenReturn(new Certificate[0]); + request = checkRequestBuilder.buildRequest(serverCall, new Metadata(), requestTime); + source = request.getAttributes().getSource(); + assertThat(source.getPrincipal()).isEmpty(); + assertThat(source.getCertificate()).isEmpty(); + destination = request.getAttributes().getDestination(); + assertThat(destination.getPrincipal()).isEmpty(); + } + + @Test + public void buildRequest_forServer_nonX509Certificate() throws Exception { + SocketAddress localAddress = new InetSocketAddress("10.0.0.2", 443); + SocketAddress remoteAddress = new InetSocketAddress("192.168.1.1", 12345); + Attributes attributes = + Attributes.newBuilder().set(Grpc.TRANSPORT_ATTR_LOCAL_ADDR, localAddress) + .set(Grpc.TRANSPORT_ATTR_REMOTE_ADDR, remoteAddress) + .set(Grpc.TRANSPORT_ATTR_SSL_SESSION, sslSession).build(); + when(serverCall.getAttributes()).thenReturn(attributes); + when(serverCall.getMethodDescriptor()).thenReturn(methodDescriptor); + Certificate nonX509Cert = mock(Certificate.class); + Certificate[] certs = new Certificate[] {nonX509Cert}; + + when(sslSession.getPeerCertificates()).thenReturn(certs); + when(sslSession.getLocalCertificates()).thenReturn(certs); + + CheckRequest request = + checkRequestBuilder.buildRequest(serverCall, new Metadata(), requestTime); + + AttributeContext.Peer source = request.getAttributes().getSource(); + assertThat(source.getPrincipal()).isEmpty(); + AttributeContext.Peer destination = request.getAttributes().getDestination(); + assertThat(destination.getPrincipal()).isEmpty(); + } + + @Test + public void buildRequest_forServer_nonInetSocketAddress() { + SocketAddress remoteAddress = mock(SocketAddress.class); + when(serverCall.getAttributes()).thenReturn( + Attributes.newBuilder().set(Grpc.TRANSPORT_ATTR_REMOTE_ADDR, remoteAddress).build()); + when(serverCall.getMethodDescriptor()).thenReturn(methodDescriptor); + CheckRequest request = + checkRequestBuilder.buildRequest(serverCall, new Metadata(), requestTime); + assertThat(request.getAttributes().getSource().hasAddress()).isFalse(); + } + + private ExtAuthzConfig buildExtAuthzConfig() throws ExtAuthzParseException { + return buildExtAuthzConfig(ListStringMatcher.newBuilder().build(), + ListStringMatcher.newBuilder().build(), true); + } + + private ExtAuthzConfig buildExtAuthzConfig(ListStringMatcher allowed, + ListStringMatcher disallowed, boolean includePeerCertificate) throws ExtAuthzParseException { + Any googleDefaultChannelCreds = Any.pack(GoogleDefaultCredentials.newBuilder().build()); + Any fakeAccessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("fake-token").build()); + ExtAuthz.Builder builder = ExtAuthz.newBuilder() + .setGrpcService(io.envoyproxy.envoy.config.core.v3.GrpcService.newBuilder() + .setGoogleGrpc(io.envoyproxy.envoy.config.core.v3.GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("test-cluster").addChannelCredentialsPlugin(googleDefaultChannelCreds) + .addCallCredentialsPlugin(fakeAccessTokenCreds).build()) + .build()) + .setIncludePeerCertificate(includePeerCertificate).setAllowedHeaders(allowed) + .setDisallowedHeaders(disallowed); + return ExtAuthzConfig.fromProto(builder.build()); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzCertificateProviderTest.java b/xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzCertificateProviderTest.java new file mode 100644 index 00000000000..fdeff595d56 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzCertificateProviderTest.java @@ -0,0 +1,140 @@ +/* + * 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.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import javax.security.auth.x500.X500Principal; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + + + +@RunWith(JUnit4.class) +public class ExtAuthzCertificateProviderTest { + private final ExtAuthzCertificateProvider provider = ExtAuthzCertificateProvider.create(); + + @Test + public void getPrincipal_ipAddressSan() throws Exception { + X509Certificate mockCert = mock(X509Certificate.class); + List ipSan = Arrays.asList(7, "192.168.1.1"); // SAN_TYPE_IP_ADDRESS + Collection> sans = Arrays.asList(ipSan); + when(mockCert.getSubjectAlternativeNames()).thenReturn(sans); + assertThat(provider.getPrincipal(mockCert)).isEqualTo("192.168.1.1"); + } + + @Test + public void getPrincipal_dnsSan() throws Exception { + X509Certificate mockCert = mock(X509Certificate.class); + List san = Arrays.asList(2, "foo.test.google.fr"); // SAN_TYPE_DNS_NAME + Collection> sans = Collections.singletonList(san); + when(mockCert.getSubjectAlternativeNames()).thenReturn(sans); + assertThat(provider.getPrincipal(mockCert)).isEqualTo("foo.test.google.fr"); + } + + @Test + public void getPrincipal_noSan_usesSubject() throws Exception { + X509Certificate mockCert = mock(X509Certificate.class); + when(mockCert.getSubjectAlternativeNames()).thenReturn(Collections.emptyList()); + X500Principal principal = new X500Principal("CN=testclient, O=gRPC authors"); + when(mockCert.getSubjectX500Principal()).thenReturn(principal); + assertThat(provider.getPrincipal(mockCert)).isEqualTo("CN=testclient,O=gRPC authors"); + } + + @Test + public void getPrincipal_nullSans_usesSubject() throws Exception { + X509Certificate mockCert = mock(X509Certificate.class); + when(mockCert.getSubjectAlternativeNames()).thenReturn(null); + X500Principal principal = new X500Principal("CN=testclient, O=gRPC authors"); + when(mockCert.getSubjectX500Principal()).thenReturn(principal); + assertThat(provider.getPrincipal(mockCert)).isEqualTo("CN=testclient,O=gRPC authors"); + } + + @Test + public void getPrincipal_ipSanWrongSize_usesDnsSan() throws Exception { + X509Certificate mockCert = mock(X509Certificate.class); + List ipSan = Collections.singletonList(7); // SAN_TYPE_IP_ADDRESS, wrong size + List dnsSan = Arrays.asList(2, "foo.test.google.fr"); // SAN_TYPE_DNS_NAME + Collection> sans = Arrays.asList(ipSan, dnsSan); + when(mockCert.getSubjectAlternativeNames()).thenReturn(sans); + assertThat(provider.getPrincipal(mockCert)).isEqualTo("foo.test.google.fr"); + } + + @Test + public void getPrincipal_ipSanWrongType_usesDnsSan() throws Exception { + X509Certificate mockCert = mock(X509Certificate.class); + // SAN_TYPE_IP_ADDRESS, wrong type + List ipSan = Arrays.asList("not-an-integer", "192.168.1.1"); + List dnsSan = Arrays.asList(2, "foo.test.google.fr"); // SAN_TYPE_DNS_NAME + Collection> sans = Arrays.asList(ipSan, dnsSan); + when(mockCert.getSubjectAlternativeNames()).thenReturn(sans); + assertThat(provider.getPrincipal(mockCert)).isEqualTo("foo.test.google.fr"); + } + + @Test + public void getPrincipal_dnsSanWrongType_usesSubject() throws Exception { + X509Certificate mockCert = mock(X509Certificate.class); + // Wrong SAN type for DNS check + List otherSan = Arrays.asList(6, "foo.test.google.fr"); // SAN_TYPE_URI + Collection> sans = Collections.singletonList(otherSan); + when(mockCert.getSubjectAlternativeNames()).thenReturn(sans); + when(mockCert.getSubjectX500Principal()).thenReturn(new X500Principal("CN=test")); + assertThat(provider.getPrincipal(mockCert)).isEqualTo("CN=test"); + } + + @Test + public void getPrincipal_sanParsingException_usesSubject() throws Exception { + X509Certificate mockCert = mock(X509Certificate.class); + when(mockCert.getSubjectAlternativeNames()).thenThrow(new CertificateParsingException()); + X500Principal principal = new X500Principal("CN=testclient, O=gRPC authors"); + when(mockCert.getSubjectX500Principal()).thenReturn(principal); + assertThat(provider.getPrincipal(mockCert)).isEqualTo("CN=testclient,O=gRPC authors"); + } + + @Test + public void getUrlPemEncodedCertificate() throws Exception { + X509Certificate mockCert = mock(X509Certificate.class); + byte[] certData = "cert-data".getBytes(StandardCharsets.UTF_8); + when(mockCert.getEncoded()).thenReturn(certData); + + String pem = "-----BEGIN CERTIFICATE-----\n" + "Y2VydC1kYXRh" // base64 of "cert-data" + + "\n-----END CERTIFICATE-----\n"; + String urlEncodedPem = URLEncoder.encode(pem, StandardCharsets.UTF_8.toString()); + assertThat(provider.getUrlPemEncodedCertificate(mockCert)).isEqualTo(urlEncodedPem); + } + + @Test + public void getUrlPemEncodedCertificate_encodingException() throws Exception { + X509Certificate mockCert = mock(X509Certificate.class); + when(mockCert.getEncoded()).thenThrow(new CertificateEncodingException("test")); + assertThrows(CertificateEncodingException.class, + () -> provider.getUrlPemEncodedCertificate(mockCert)); + } +} From 0ce7b7948059a2fbdb5b73319138be68f45b44dc Mon Sep 17 00:00:00 2001 From: Saurav Date: Fri, 24 Oct 2025 13:58:34 +0000 Subject: [PATCH 4/8] feat(xds): Add header mutations library This commit introduces a library for handling header mutations as specified by the xDS protocol. This library provides the core functionality for modifying request and response headers based on a set of rules. The main components of this library are: - `HeaderMutator`: Applies header mutations to `Metadata` objects. - `HeaderMutationFilter`: Filters header mutations based on a set of configurable rules, such as disallowing mutations of system headers. - `HeaderMutations`: A value class that represents the set of mutations to be applied to request and response headers. - `HeaderMutationDisallowedException`: An exception that is thrown when a disallowed header mutation is attempted. This commit also includes comprehensive unit tests for the new library. --- .../HeaderMutationDisallowedException.java | 32 ++ .../headermutations/HeaderMutationFilter.java | 172 ++++++++++ .../headermutations/HeaderMutations.java | 58 ++++ .../headermutations/HeaderMutator.java | 143 ++++++++ .../HeaderMutationFilterTest.java | 245 ++++++++++++++ .../headermutations/HeaderMutationsTest.java | 50 +++ .../headermutations/HeaderMutatorTest.java | 311 ++++++++++++++++++ 7 files changed, 1011 insertions(+) create mode 100644 xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationDisallowedException.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationFilter.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutations.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutator.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationFilterTest.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationsTest.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutatorTest.java diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationDisallowedException.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationDisallowedException.java new file mode 100644 index 00000000000..b8d4eb582fb --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationDisallowedException.java @@ -0,0 +1,32 @@ +/* + * Copyright 2024 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.headermutations; + +import io.grpc.Status; +import io.grpc.StatusException; + +/** + * Exception thrown when a header mutation is disallowed. + */ +public final class HeaderMutationDisallowedException extends StatusException { + + private static final long serialVersionUID = 1L; + + public HeaderMutationDisallowedException(String message) { + super(Status.INTERNAL.withDescription(message)); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationFilter.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationFilter.java new file mode 100644 index 00000000000..0452354d823 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationFilter.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.headermutations; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import io.envoyproxy.envoy.config.core.v3.HeaderValueOption; +import io.grpc.xds.internal.headermutations.HeaderMutations.RequestHeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderMutations.ResponseHeaderMutations; +import java.util.Collection; +import java.util.Locale; +import java.util.Optional; +import java.util.function.Predicate; + +/** + * The HeaderMutationFilter class is responsible for filtering header mutations based on a given set + * of rules. + */ +public interface HeaderMutationFilter { + + /** + * A factory for creating {@link HeaderMutationFilter} instances. + */ + @FunctionalInterface + interface Factory { + /** + * Creates a new instance of {@code HeaderMutationFilter}. + * + * @param mutationRules The rules for header mutations. If an empty {@code Optional} is + * provided, all header mutations are allowed by default, except for certain system + * headers. If a {@link HeaderMutationRulesConfig} is provided, mutations will be + * filtered based on the specified rules. + */ + HeaderMutationFilter create(Optional mutationRules); + } + + /** + * The default factory for creating {@link HeaderMutationFilter} instances. + */ + Factory INSTANCE = HeaderMutationFilterImpl::new; + + /** + * Filters the given header mutations based on the configured rules and returns the allowed + * mutations. + * + * @param mutations The header mutations to filter + * @return The allowed header mutations. + * @throws HeaderMutationDisallowedException if a disallowed mutation is encountered and the rules + * specify that this should be an error. + */ + HeaderMutations filter(HeaderMutations mutations) throws HeaderMutationDisallowedException; + + /** Default implementation of {@link HeaderMutationFilter}. */ + final class HeaderMutationFilterImpl implements HeaderMutationFilter { + private final Optional mutationRules; + + /** + * Set of HTTP/2 pseudo-headers and the host header that are critical for routing and protocol + * correctness. These headers cannot be mutated by user configuration. + */ + private static final ImmutableSet IMMUTABLE_HEADERS = + ImmutableSet.of("host", ":authority", ":scheme", ":method"); + + private HeaderMutationFilterImpl(Optional mutationRules) { // NOPMD + this.mutationRules = mutationRules; + } + + @Override + public HeaderMutations filter(HeaderMutations mutations) + throws HeaderMutationDisallowedException { + ImmutableList allowedRequestHeaders = + filterCollection(mutations.requestMutations().headers(), + header -> isHeaderMutationAllowed(header.getHeader().getKey()) + && !appendsSystemHeader(header)); + ImmutableList allowedRequestHeadersToRemove = + filterCollection(mutations.requestMutations().headersToRemove(), + header -> isHeaderMutationAllowed(header) && isHeaderRemovalAllowed(header)); + ImmutableList allowedResponseHeaders = + filterCollection(mutations.responseMutations().headers(), + header -> isHeaderMutationAllowed(header.getHeader().getKey()) + && !appendsSystemHeader(header)); + return HeaderMutations.create( + RequestHeaderMutations.create(allowedRequestHeaders, allowedRequestHeadersToRemove), + ResponseHeaderMutations.create(allowedResponseHeaders)); + } + + /** + * A generic helper to filter a collection based on a predicate. + * + * @param items The collection of items to filter. + * @param isAllowedPredicate The predicate to apply to each item. + * @param The type of items in the collection. + * @return An immutable list of allowed items. + * @throws HeaderMutationDisallowedException if an item is disallowed and disallowIsError is + * true. + */ + private ImmutableList filterCollection(Collection items, + Predicate isAllowedPredicate) throws HeaderMutationDisallowedException { + ImmutableList.Builder allowed = ImmutableList.builder(); + for (T item : items) { + if (isAllowedPredicate.test(item)) { + allowed.add(item); + } else if (disallowIsError()) { + throw new HeaderMutationDisallowedException( + "Header mutation disallowed for header: " + item); + } + } + return allowed.build(); + } + + private boolean isHeaderRemovalAllowed(String headerKey) { + return !isSystemHeaderKey(headerKey); + } + + private boolean appendsSystemHeader(HeaderValueOption headerValueOption) { + String key = headerValueOption.getHeader().getKey(); + boolean isAppend = headerValueOption + .getAppendAction() == HeaderValueOption.HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD; + return isAppend && isSystemHeaderKey(key); + } + + private boolean isSystemHeaderKey(String key) { + return key.startsWith(":") || key.toLowerCase(Locale.ROOT).equals("host"); + } + + private boolean isHeaderMutationAllowed(String headerName) { + String lowerCaseHeaderName = headerName.toLowerCase(Locale.ROOT); + if (IMMUTABLE_HEADERS.contains(lowerCaseHeaderName)) { + return false; + } + return mutationRules.map(rules -> isHeaderMutationAllowed(lowerCaseHeaderName, rules)) + .orElse(true); + } + + private boolean isHeaderMutationAllowed(String lowerCaseHeaderName, + HeaderMutationRulesConfig rules) { + // TODO(sauravzg): The priority is slightly unclear in the spec. + // Both `disallowAll` and `disallow_expression` take precedence over `all other + // settings`. + // `allow_expression` takes precedence over everything except `disallow_expression`. + // This is a conflict between ordering for `allow_expression` and `disallowAll`. + // Choosing to proceed with current envoy implementation which favors `allow_expression` over + // `disallowAll`. + if (rules.disallowExpression().isPresent() + && rules.disallowExpression().get().matcher(lowerCaseHeaderName).matches()) { + return false; + } + if (rules.allowExpression().isPresent()) { + return rules.allowExpression().get().matcher(lowerCaseHeaderName).matches(); + } + return !rules.disallowAll(); + } + + private boolean disallowIsError() { + return mutationRules.map(HeaderMutationRulesConfig::disallowIsError).orElse(false); + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutations.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutations.java new file mode 100644 index 00000000000..e0cb3daede3 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutations.java @@ -0,0 +1,58 @@ +/* + * 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.headermutations; + +import com.google.auto.value.AutoValue; +import com.google.common.collect.ImmutableList; +import io.envoyproxy.envoy.config.core.v3.HeaderValueOption; + +/** A collection of header mutations for both request and response headers. */ +@AutoValue +public abstract class HeaderMutations { + + public static HeaderMutations create(RequestHeaderMutations requestMutations, + ResponseHeaderMutations responseMutations) { + return new AutoValue_HeaderMutations(requestMutations, responseMutations); + } + + public abstract RequestHeaderMutations requestMutations(); + + public abstract ResponseHeaderMutations responseMutations(); + + /** Represents mutations for request headers. */ + @AutoValue + public abstract static class RequestHeaderMutations { + public static RequestHeaderMutations create(ImmutableList headers, + ImmutableList headersToRemove) { + return new AutoValue_HeaderMutations_RequestHeaderMutations(headers, headersToRemove); + } + + public abstract ImmutableList headers(); + + public abstract ImmutableList headersToRemove(); + } + + /** Represents mutations for response headers. */ + @AutoValue + public abstract static class ResponseHeaderMutations { + public static ResponseHeaderMutations create(ImmutableList headers) { + return new AutoValue_HeaderMutations_ResponseHeaderMutations(headers); + } + + public abstract ImmutableList headers(); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutator.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutator.java new file mode 100644 index 00000000000..de5b946bbc7 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutator.java @@ -0,0 +1,143 @@ +/* + * 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.headermutations; + +import com.google.common.io.BaseEncoding; +import io.envoyproxy.envoy.config.core.v3.HeaderValue; +import io.envoyproxy.envoy.config.core.v3.HeaderValueOption; +import io.envoyproxy.envoy.config.core.v3.HeaderValueOption.HeaderAppendAction; +import io.grpc.Metadata; +import io.grpc.xds.internal.headermutations.HeaderMutations.RequestHeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderMutations.ResponseHeaderMutations; +import java.nio.charset.StandardCharsets; +import java.util.logging.Logger; + +/** + * The HeaderMutator class is an implementation of the HeaderMutator interface. It provides methods + * to apply header mutations to a given set of headers based on a given set of rules. + */ +public interface HeaderMutator { + /** + * Creates a new instance of {@code HeaderMutator}. + */ + static HeaderMutator create() { + return new HeaderMutatorImpl(); + } + + /** + * Applies the given header mutations to the provided metadata headers. + * + * @param mutations The header mutations to apply. + * @param headers The metadata headers to which the mutations will be applied. + */ + void applyRequestMutations(RequestHeaderMutations mutations, Metadata headers); + + + /** + * Applies the given header mutations to the provided metadata headers. + * + * @param mutations The header mutations to apply. + * @param headers The metadata headers to which the mutations will be applied. + */ + void applyResponseMutations(ResponseHeaderMutations mutations, Metadata headers); + + /** Default implementation of {@link HeaderMutator}. */ + final class HeaderMutatorImpl implements HeaderMutator { + + private static final Logger logger = Logger.getLogger(HeaderMutatorImpl.class.getName()); + + @Override + public void applyRequestMutations(final RequestHeaderMutations mutations, Metadata headers) { + // TODO(sauravzg): The specification is not clear on order of header removals and additions. + // in case of conflicts. Copying the order from Envoy here, which does removals at the end. + applyHeaderUpdates(mutations.headers(), headers); + for (String headerToRemove : mutations.headersToRemove()) { + headers.discardAll(Metadata.Key.of(headerToRemove, Metadata.ASCII_STRING_MARSHALLER)); + } + } + + @Override + public void applyResponseMutations(final ResponseHeaderMutations mutations, Metadata headers) { + applyHeaderUpdates(mutations.headers(), headers); + } + + private void applyHeaderUpdates(final Iterable headerOptions, + Metadata headers) { + for (HeaderValueOption headerOption : headerOptions) { + HeaderValue headerValue = headerOption.getHeader(); + updateHeader(headerValue, headerOption.getAppendAction(), headers); + } + } + + private void updateHeader(final HeaderValue header, final HeaderAppendAction action, + Metadata mutableHeaders) { + if (header.getKey().endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + updateHeader(action, Metadata.Key.of(header.getKey(), Metadata.BINARY_BYTE_MARSHALLER), + getBinaryHeaderValue(header), mutableHeaders); + } else { + updateHeader(action, Metadata.Key.of(header.getKey(), Metadata.ASCII_STRING_MARSHALLER), + getAsciiValue(header), mutableHeaders); + } + } + + private void updateHeader(final HeaderAppendAction action, final Metadata.Key key, + final T value, Metadata mutableHeaders) { + switch (action) { + case APPEND_IF_EXISTS_OR_ADD: + mutableHeaders.put(key, value); + break; + case ADD_IF_ABSENT: + if (!mutableHeaders.containsKey(key)) { + mutableHeaders.put(key, value); + } + break; + case OVERWRITE_IF_EXISTS_OR_ADD: + mutableHeaders.discardAll(key); + mutableHeaders.put(key, value); + break; + case OVERWRITE_IF_EXISTS: + if (mutableHeaders.containsKey(key)) { + mutableHeaders.discardAll(key); + mutableHeaders.put(key, value); + } + break; + case UNRECOGNIZED: + // Ignore invalid value + logger.warning("Unrecognized HeaderAppendAction: " + action); + break; + default: + // Should be unreachable unless there's a proto schema mismatch. + logger.warning("Unknown HeaderAppendAction: " + action); + } + } + + private byte[] getBinaryHeaderValue(HeaderValue header) { + return BaseEncoding.base64().decode(getAsciiValue(header)); + } + + private String getAsciiValue(HeaderValue header) { + // TODO(sauravzg): GRPC only supports base64 encoded binary headers, so we decode bytes to + // String using `StandardCharsets.US_ASCII`. + // Envoy's spec `raw_value` specification can contain non UTF-8 bytes, so this may potentially + // cause an exception or corruption. + if (!header.getRawValue().isEmpty()) { + return header.getRawValue().toString(StandardCharsets.US_ASCII); + } + return header.getValue(); + } + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationFilterTest.java b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationFilterTest.java new file mode 100644 index 00000000000..e73460924c7 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationFilterTest.java @@ -0,0 +1,245 @@ +/* + * 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.headermutations; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableList; +import io.envoyproxy.envoy.config.core.v3.HeaderValue; +import io.envoyproxy.envoy.config.core.v3.HeaderValueOption; +import io.envoyproxy.envoy.config.core.v3.HeaderValueOption.HeaderAppendAction; +import io.grpc.xds.internal.headermutations.HeaderMutations.RequestHeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderMutations.ResponseHeaderMutations; +import java.util.Optional; +import java.util.regex.Pattern; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class HeaderMutationFilterTest { + + private static HeaderValueOption header(String key, String value) { + return HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey(key).setValue(value)).build(); + } + + private static HeaderValueOption header(String key, String value, HeaderAppendAction action) { + return HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey(key).setValue(value)).setAppendAction(action) + .build(); + } + + @Test + public void filter_removesImmutableHeaders() throws HeaderMutationDisallowedException { + HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.empty()); + HeaderMutations mutations = HeaderMutations.create( + RequestHeaderMutations.create( + ImmutableList.of(header("add-key", "add-value"), header(":authority", "new-authority"), + header("host", "new-host"), header(":scheme", "https"), header(":method", "PUT")), + ImmutableList.of("remove-key", "host", ":authority", ":scheme", ":method")), + ResponseHeaderMutations.create(ImmutableList.of(header("resp-add-key", "resp-add-value"), + header(":scheme", "https")))); + + HeaderMutations filtered = filter.filter(mutations); + + assertThat(filtered.requestMutations().headers()) + .containsExactly(header("add-key", "add-value")); + assertThat(filtered.requestMutations().headersToRemove()).containsExactly("remove-key"); + assertThat(filtered.responseMutations().headers()) + .containsExactly(header("resp-add-key", "resp-add-value")); + } + + @Test + public void filter_cannotAppendToSystemHeaders() throws HeaderMutationDisallowedException { + HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.empty()); + HeaderMutations mutations = + HeaderMutations.create( + RequestHeaderMutations.create( + ImmutableList.of( + header("add-key", "add-value", HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD), + header(":authority", "new-authority", + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD), + header("host", "new-host", HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD), + header(":path", "/new-path", HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD)), + ImmutableList.of()), + ResponseHeaderMutations.create(ImmutableList + .of(header("host", "new-host", HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD)))); + + HeaderMutations filtered = filter.filter(mutations); + + assertThat(filtered.requestMutations().headers()).containsExactly( + header("add-key", "add-value", HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD)); + assertThat(filtered.responseMutations().headers()).isEmpty(); + } + + @Test + public void filter_cannotRemoveSystemHeaders() throws HeaderMutationDisallowedException { + HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.empty()); + HeaderMutations mutations = HeaderMutations.create( + RequestHeaderMutations.create(ImmutableList.of(), + ImmutableList.of("remove-key", "host", ":foo", ":bar")), + ResponseHeaderMutations.create(ImmutableList.of())); + + HeaderMutations filtered = filter.filter(mutations); + + assertThat(filtered.requestMutations().headersToRemove()).containsExactly("remove-key"); + } + + @Test + public void filter_canOverrideSystemHeadersNotInImmutableHeaders() + throws HeaderMutationDisallowedException { + HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.empty()); + HeaderMutations mutations = HeaderMutations.create( + RequestHeaderMutations.create( + ImmutableList.of(header("user-agent", "new-agent"), + header(":path", "/new/path", HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD), + header(":grpc-trace-bin", "binary-value", HeaderAppendAction.ADD_IF_ABSENT)), + ImmutableList.of()), + ResponseHeaderMutations.create(ImmutableList + .of(header(":alt-svc", "h3=:443", HeaderAppendAction.OVERWRITE_IF_EXISTS)))); + + HeaderMutations filtered = filter.filter(mutations); + + assertThat(filtered.requestMutations().headers()).containsExactly( + header("user-agent", "new-agent"), + header(":path", "/new/path", HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD), + header(":grpc-trace-bin", "binary-value", HeaderAppendAction.ADD_IF_ABSENT)); + assertThat(filtered.responseMutations().headers()) + .containsExactly(header(":alt-svc", "h3=:443", HeaderAppendAction.OVERWRITE_IF_EXISTS)); + } + + @Test + public void filter_disallowAll_disablesAllModifications() + throws HeaderMutationDisallowedException { + HeaderMutationRulesConfig rules = HeaderMutationRulesConfig.builder().disallowAll(true).build(); + HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.of(rules)); + HeaderMutations mutations = HeaderMutations.create( + RequestHeaderMutations.create(ImmutableList.of(header("add-key", "add-value")), + ImmutableList.of("remove-key")), + ResponseHeaderMutations.create(ImmutableList.of(header("resp-add-key", "resp-add-value")))); + + HeaderMutations filtered = filter.filter(mutations); + + assertThat(filtered.requestMutations().headers()).isEmpty(); + assertThat(filtered.requestMutations().headersToRemove()).isEmpty(); + assertThat(filtered.responseMutations().headers()).isEmpty(); + } + + @Test + public void filter_disallowExpression_filtersRelevantExpressions() + throws HeaderMutationDisallowedException { + HeaderMutationRulesConfig rules = HeaderMutationRulesConfig.builder() + .disallowExpression(Pattern.compile("^x-private-.*")).build(); + HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.of(rules)); + HeaderMutations mutations = HeaderMutations.create( + RequestHeaderMutations.create( + ImmutableList.of(header("x-public", "value"), header("x-private-key", "value")), + ImmutableList.of("x-public-remove", "x-private-remove")), + ResponseHeaderMutations.create( + ImmutableList.of(header("x-public-resp", "value"), header("x-private-resp", "value")))); + + HeaderMutations filtered = filter.filter(mutations); + + assertThat(filtered.requestMutations().headers()).containsExactly(header("x-public", "value")); + assertThat(filtered.requestMutations().headersToRemove()).containsExactly("x-public-remove"); + assertThat(filtered.responseMutations().headers()) + .containsExactly(header("x-public-resp", "value")); + } + + @Test + public void filter_allowExpression_onlyAllowsRelevantExpressions() + throws HeaderMutationDisallowedException { + HeaderMutationRulesConfig rules = HeaderMutationRulesConfig.builder() + .allowExpression(Pattern.compile("^x-allowed-.*")).build(); + HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.of(rules)); + HeaderMutations mutations = + HeaderMutations.create( + RequestHeaderMutations.create( + ImmutableList.of(header("x-allowed-key", "value"), + header("not-allowed-key", "value")), + ImmutableList.of("x-allowed-remove", "not-allowed-remove")), + ResponseHeaderMutations.create(ImmutableList.of(header("x-allowed-resp-key", "value"), + header("not-allowed-resp-key", "value")))); + + HeaderMutations filtered = filter.filter(mutations); + + assertThat(filtered.requestMutations().headers()) + .containsExactly(header("x-allowed-key", "value")); + assertThat(filtered.requestMutations().headersToRemove()).containsExactly("x-allowed-remove"); + assertThat(filtered.responseMutations().headers()) + .containsExactly(header("x-allowed-resp-key", "value")); + } + + @Test + public void filter_allowExpression_overridesDisallowAll() + throws HeaderMutationDisallowedException { + HeaderMutationRulesConfig rules = HeaderMutationRulesConfig.builder().disallowAll(true) + .allowExpression(Pattern.compile("^x-allowed-.*")).build(); + HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.of(rules)); + HeaderMutations mutations = HeaderMutations.create( + RequestHeaderMutations.create( + ImmutableList.of(header("x-allowed-key", "value"), header("not-allowed", "value")), + ImmutableList.of("x-allowed-remove", "not-allowed-remove")), + ResponseHeaderMutations.create(ImmutableList.of(header("x-allowed-resp-key", "value"), + header("not-allowed-resp-key", "value")))); + + HeaderMutations filtered = filter.filter(mutations); + + assertThat(filtered.requestMutations().headers()) + .containsExactly(header("x-allowed-key", "value")); + assertThat(filtered.requestMutations().headersToRemove()).containsExactly("x-allowed-remove"); + assertThat(filtered.responseMutations().headers()) + .containsExactly(header("x-allowed-resp-key", "value")); + } + + @Test(expected = HeaderMutationDisallowedException.class) + public void filter_disallowIsError_throwsExceptionOnDisallowed() + throws HeaderMutationDisallowedException { + HeaderMutationRulesConfig rules = + HeaderMutationRulesConfig.builder().disallowAll(true).disallowIsError(true).build(); + HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.of(rules)); + HeaderMutations mutations = HeaderMutations.create(RequestHeaderMutations + .create(ImmutableList.of(header("add-key", "add-value")), ImmutableList.of()), + ResponseHeaderMutations.create(ImmutableList.of())); + filter.filter(mutations); + } + + @Test(expected = HeaderMutationDisallowedException.class) + public void filter_disallowIsError_throwsExceptionOnDisallowedRemove() + throws HeaderMutationDisallowedException { + HeaderMutationRulesConfig rules = + HeaderMutationRulesConfig.builder().disallowAll(true).disallowIsError(true).build(); + HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.of(rules)); + HeaderMutations mutations = HeaderMutations.create( + RequestHeaderMutations.create(ImmutableList.of(), ImmutableList.of("remove-key")), + ResponseHeaderMutations.create(ImmutableList.of())); + filter.filter(mutations); + } + + @Test(expected = HeaderMutationDisallowedException.class) + public void filter_disallowIsError_throwsExceptionOnDisallowedResponseHeader() + throws HeaderMutationDisallowedException { + HeaderMutationRulesConfig rules = + HeaderMutationRulesConfig.builder().disallowAll(true).disallowIsError(true).build(); + HeaderMutationFilter filter = HeaderMutationFilter.INSTANCE.create(Optional.of(rules)); + HeaderMutations mutations = HeaderMutations.create( + RequestHeaderMutations.create(ImmutableList.of(), ImmutableList.of()), + ResponseHeaderMutations.create(ImmutableList.of(header("resp-add-key", "resp-add-value")))); + filter.filter(mutations); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationsTest.java b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationsTest.java new file mode 100644 index 00000000000..f1dc0561692 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationsTest.java @@ -0,0 +1,50 @@ +/* + * 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.headermutations; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableList; +import io.envoyproxy.envoy.config.core.v3.HeaderValue; +import io.envoyproxy.envoy.config.core.v3.HeaderValueOption; +import io.grpc.xds.internal.headermutations.HeaderMutations.RequestHeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderMutations.ResponseHeaderMutations; +import org.junit.Test; + +public class HeaderMutationsTest { + @Test + public void testCreate() { + HeaderValueOption reqHeader = HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey("req-key").setValue("req-value").build()) + .build(); + RequestHeaderMutations requestMutations = RequestHeaderMutations + .create(ImmutableList.of(reqHeader), ImmutableList.of("remove-req-key")); + assertThat(requestMutations.headers()).containsExactly(reqHeader); + assertThat(requestMutations.headersToRemove()).containsExactly("remove-req-key"); + + HeaderValueOption respHeader = HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey("resp-key").setValue("resp-value").build()) + .build(); + ResponseHeaderMutations responseMutations = + ResponseHeaderMutations.create(ImmutableList.of(respHeader)); + assertThat(responseMutations.headers()).containsExactly(respHeader); + + HeaderMutations mutations = HeaderMutations.create(requestMutations, responseMutations); + assertThat(mutations.requestMutations()).isEqualTo(requestMutations); + assertThat(mutations.responseMutations()).isEqualTo(responseMutations); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutatorTest.java b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutatorTest.java new file mode 100644 index 00000000000..df6ce383d8c --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutatorTest.java @@ -0,0 +1,311 @@ +/* + * 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.headermutations; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableList; +import com.google.common.io.BaseEncoding; +import com.google.common.testing.TestLogHandler; +import com.google.protobuf.ByteString; +import io.envoyproxy.envoy.config.core.v3.HeaderValue; +import io.envoyproxy.envoy.config.core.v3.HeaderValueOption; +import io.envoyproxy.envoy.config.core.v3.HeaderValueOption.HeaderAppendAction; +import io.grpc.Metadata; +import io.grpc.xds.internal.headermutations.HeaderMutations.RequestHeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderMutations.ResponseHeaderMutations; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class HeaderMutatorTest { + + private static final Metadata.Key ASCII_KEY = + Metadata.Key.of("some-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key BINARY_KEY = + Metadata.Key.of("some-key-bin", Metadata.BINARY_BYTE_MARSHALLER); + private static final Metadata.Key APPEND_KEY = + Metadata.Key.of("append-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key ADD_KEY = + Metadata.Key.of("add-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key OVERWRITE_KEY = + Metadata.Key.of("overwrite-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key REMOVE_KEY = + Metadata.Key.of("remove-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key NEW_ADD_KEY = + Metadata.Key.of("new-add-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key NEW_OVERWRITE_KEY = + Metadata.Key.of("new-overwrite-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key OVERWRITE_IF_EXISTS_KEY = + Metadata.Key.of("overwrite-if-exists-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key OVERWRITE_IF_EXISTS_ABSENT_KEY = + Metadata.Key.of("overwrite-if-exists-absent-key", Metadata.ASCII_STRING_MARSHALLER); + + private final HeaderMutator headerMutator = HeaderMutator.create(); + + private static final TestLogHandler logHandler = new TestLogHandler(); + private static final Logger logger = + Logger.getLogger(HeaderMutator.HeaderMutatorImpl.class.getName()); + + @Before + public void setUp() { + logHandler.clear(); + logger.addHandler(logHandler); + logger.setLevel(Level.WARNING); + } + + @After + public void tearDown() { + logger.removeHandler(logHandler); + } + + private static HeaderValueOption header(String key, String value, HeaderAppendAction action) { + return HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey(key).setValue(value)).setAppendAction(action) + .build(); + } + + @Test + public void applyRequestMutations_asciiHeaders() { + Metadata headers = new Metadata(); + headers.put(APPEND_KEY, "append-value-1"); + headers.put(ADD_KEY, "add-value-original"); + headers.put(OVERWRITE_KEY, "overwrite-value-original"); + headers.put(REMOVE_KEY, "remove-value-original"); + headers.put(OVERWRITE_IF_EXISTS_KEY, "original-value"); + + RequestHeaderMutations mutations = RequestHeaderMutations.create(ImmutableList.of( + // Append to existing header + header(APPEND_KEY.name(), "append-value-2", HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD), + // Try to add to an existing header (should be no-op) + header(ADD_KEY.name(), "add-value-new", HeaderAppendAction.ADD_IF_ABSENT), + // Add a new header + header(NEW_ADD_KEY.name(), "new-add-value", HeaderAppendAction.ADD_IF_ABSENT), + // Overwrite an existing header + header(OVERWRITE_KEY.name(), "overwrite-value-new", + HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD), + // Overwrite a new header + header(NEW_OVERWRITE_KEY.name(), "new-overwrite-value", + HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD), + // Overwrite an existing header if it exists + header(OVERWRITE_IF_EXISTS_KEY.name(), "new-value", HeaderAppendAction.OVERWRITE_IF_EXISTS), + // Try to overwrite a header that does not exist + header(OVERWRITE_IF_EXISTS_ABSENT_KEY.name(), "new-value", + HeaderAppendAction.OVERWRITE_IF_EXISTS)), + ImmutableList.of(REMOVE_KEY.name())); + + headerMutator.applyRequestMutations(mutations, headers); + + assertThat(headers.getAll(APPEND_KEY)).containsExactly("append-value-1", "append-value-2"); + assertThat(headers.get(ADD_KEY)).isEqualTo("add-value-original"); + assertThat(headers.get(NEW_ADD_KEY)).isEqualTo("new-add-value"); + assertThat(headers.get(OVERWRITE_KEY)).isEqualTo("overwrite-value-new"); + assertThat(headers.get(NEW_OVERWRITE_KEY)).isEqualTo("new-overwrite-value"); + assertThat(headers.containsKey(REMOVE_KEY)).isFalse(); + assertThat(headers.get(OVERWRITE_IF_EXISTS_KEY)).isEqualTo("new-value"); + assertThat(headers.containsKey(OVERWRITE_IF_EXISTS_ABSENT_KEY)).isFalse(); + } + + @Test + public void applyRequestMutations_InvalidAppendAction_isIgnored() { + Metadata headers = new Metadata(); + headers.put(ASCII_KEY, "value1"); + headerMutator + .applyRequestMutations( + RequestHeaderMutations + .create( + ImmutableList.of( + HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey(ASCII_KEY.name()) + .setValue("value2")) + .setAppendActionValue(-1).build(), + HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey(BINARY_KEY.name()) + .setValue("value2")) + .setAppendActionValue(-5).build()), + ImmutableList.of()), + headers); + assertThat(headers.getAll(ASCII_KEY)).containsExactly("value1"); + } + + @Test + public void applyRequestMutations_removalHasPriority() { + Metadata headers = new Metadata(); + headers.put(REMOVE_KEY, "value"); + RequestHeaderMutations mutations = RequestHeaderMutations.create( + ImmutableList.of( + header(REMOVE_KEY.name(), "new-value", HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD)), + ImmutableList.of(REMOVE_KEY.name())); + + headerMutator.applyRequestMutations(mutations, headers); + + assertThat(headers.containsKey(REMOVE_KEY)).isFalse(); + } + + @Test + public void applyRequestMutations_binary_withBase64RawValue() { + Metadata headers = new Metadata(); + byte[] value = new byte[] {1, 2, 3}; + HeaderValueOption option = HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey(BINARY_KEY.name()).setRawValue( + ByteString.copyFrom(BaseEncoding.base64().encode(value), StandardCharsets.US_ASCII))) + .setAppendAction(HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD).build(); + headerMutator.applyRequestMutations( + RequestHeaderMutations.create(ImmutableList.of(option), ImmutableList.of()), headers); + assertThat(headers.get(BINARY_KEY)).isEqualTo(value); + } + + @Test + public void applyRequestMutations_binary_withBase64Value() { + Metadata headers = new Metadata(); + byte[] value = new byte[] {1, 2, 3}; + String base64Value = BaseEncoding.base64().encode(value); + HeaderValueOption option = HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey(BINARY_KEY.name()).setValue(base64Value)) + .setAppendAction(HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD).build(); + + headerMutator.applyRequestMutations( + RequestHeaderMutations.create(ImmutableList.of(option), ImmutableList.of()), headers); + assertThat(headers.get(BINARY_KEY)).isEqualTo(value); + } + + @Test + public void applyRequestMutations_ascii_withRawValue() { + Metadata headers = new Metadata(); + byte[] value = "raw-value".getBytes(StandardCharsets.US_ASCII); + HeaderValueOption option = HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey(ASCII_KEY.name()) + .setRawValue(ByteString.copyFrom(value))) + .setAppendAction(HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD).build(); + headerMutator.applyRequestMutations( + RequestHeaderMutations.create(ImmutableList.of(option), ImmutableList.of()), headers); + assertThat(headers.get(Metadata.Key.of(ASCII_KEY.name(), Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("raw-value"); + } + + @Test + public void applyResponseMutations_asciiHeaders() { + Metadata headers = new Metadata(); + headers.put(APPEND_KEY, "append-value-1"); + headers.put(ADD_KEY, "add-value-original"); + headers.put(OVERWRITE_KEY, "overwrite-value-original"); + + ResponseHeaderMutations mutations = ResponseHeaderMutations.create(ImmutableList.of( + header(APPEND_KEY.name(), "append-value-2", HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD), + header(ADD_KEY.name(), "add-value-new", HeaderAppendAction.ADD_IF_ABSENT), + header(NEW_ADD_KEY.name(), "new-add-value", HeaderAppendAction.ADD_IF_ABSENT), + header(OVERWRITE_KEY.name(), "overwrite-value-new", + HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD), + header(NEW_OVERWRITE_KEY.name(), "new-overwrite-value", + HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD))); + + headerMutator.applyResponseMutations(mutations, headers); + + assertThat(headers.getAll(APPEND_KEY)).containsExactly("append-value-1", "append-value-2"); + assertThat(headers.get(ADD_KEY)).isEqualTo("add-value-original"); + assertThat(headers.get(NEW_ADD_KEY)).isEqualTo("new-add-value"); + assertThat(headers.get(OVERWRITE_KEY)).isEqualTo("overwrite-value-new"); + assertThat(headers.get(NEW_OVERWRITE_KEY)).isEqualTo("new-overwrite-value"); + } + + + @Test + public void applyResponseMutations_InvalidAppendAction_isIgnored() { + Metadata headers = new Metadata(); + headers.put(ASCII_KEY, "value1"); + headerMutator + .applyResponseMutations( + ResponseHeaderMutations + .create( + ImmutableList.of( + HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey(ASCII_KEY.name()) + .setValue("value2")) + .setAppendActionValue(-1).build(), + HeaderValueOption + .newBuilder().setHeader(HeaderValue.newBuilder() + .setKey(BINARY_KEY.name()).setValue("value2")) + .setAppendActionValue(-5).build())), + headers); + assertThat(headers.getAll(ASCII_KEY)).containsExactly("value1"); + } + + @Test + public void applyResponseMutations_binary_withBase64RawValue() { + Metadata headers = new Metadata(); + byte[] value = new byte[] {1, 2, 3}; + HeaderValueOption option = HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey(BINARY_KEY.name()).setRawValue( + ByteString.copyFrom(BaseEncoding.base64().encode(value), StandardCharsets.US_ASCII))) + .setAppendAction(HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD).build(); + headerMutator.applyResponseMutations(ResponseHeaderMutations.create(ImmutableList.of(option)), + headers); + assertThat(headers.get(BINARY_KEY)).isEqualTo(value); + } + + @Test + public void applyResponseMutations_binary_withBase64Value() { + Metadata headers = new Metadata(); + byte[] value = new byte[] {1, 2, 3}; + String base64Value = BaseEncoding.base64().encode(value); + HeaderValueOption option = HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey(BINARY_KEY.name()).setValue(base64Value)) + .setAppendAction(HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD).build(); + + headerMutator.applyResponseMutations(ResponseHeaderMutations.create(ImmutableList.of(option)), + headers); + assertThat(headers.get(BINARY_KEY)).isEqualTo(value); + } + + @Test + public void applyResponseMutations_ascii_withRawValue() { + Metadata headers = new Metadata(); + byte[] value = "raw-value".getBytes(StandardCharsets.US_ASCII); + HeaderValueOption option = HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey(ASCII_KEY.name()) + .setRawValue(ByteString.copyFrom(value))) + .setAppendAction(HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD).build(); + + headerMutator.applyResponseMutations(ResponseHeaderMutations.create(ImmutableList.of(option)), + headers); + assertThat(headers.get(Metadata.Key.of(ASCII_KEY.name(), Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("raw-value"); + } + + @Test + public void applyRequestMutations_unrecognizedAction_logsWarning() { + Metadata headers = new Metadata(); + RequestHeaderMutations mutations = + RequestHeaderMutations.create(ImmutableList.of(HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey("key").setValue("value")) + .setAppendActionValue(-1).build()), ImmutableList.of()); + headerMutator.applyRequestMutations(mutations, headers); + + List records = logHandler.getStoredLogRecords(); + assertThat(records).hasSize(1); + assertThat(records.get(0).getMessage()) + .contains("Unrecognized HeaderAppendAction: UNRECOGNIZED"); + } +} From 4599975f379a89ce345f0c860289a01ca191bd8d Mon Sep 17 00:00:00 2001 From: Saurav Date: Sun, 2 Nov 2025 19:36:53 +0000 Subject: [PATCH 5/8] feat(xds): Implement response handling for external authorization This commit introduces the `CheckResponseHandler` and `AuthzResponse` classes, which are responsible for processing responses from the external authorization service. The `CheckResponseHandler` parses the `CheckResponse` protobuf, determines whether the request should be allowed or denied, and applies any header mutations specified in the response. It handles both `OkHttpResponse` and `DeniedHttpResponse` messages. The `AuthzResponse` class is a value object that represents the outcome of the authorization check, encapsulating the decision (allow or deny), the status to be returned to the client (for deny decisions), and any header mutations. This commit also includes unit tests for the new components. --- .../xds/internal/extauthz/AuthzResponse.java | 91 +++++++++ .../extauthz/CheckResponseHandler.java | 148 ++++++++++++++ .../internal/extauthz/AuthzResponseTest.java | 66 ++++++ .../extauthz/CheckResponseHandlerTest.java | 191 ++++++++++++++++++ 4 files changed, 496 insertions(+) create mode 100644 xds/src/main/java/io/grpc/xds/internal/extauthz/AuthzResponse.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/extauthz/CheckResponseHandler.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/extauthz/AuthzResponseTest.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/extauthz/CheckResponseHandlerTest.java 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..530badb631b --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extauthz/AuthzResponse.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 com.google.auto.value.AutoValue; +import com.google.common.collect.ImmutableList; +import io.grpc.Metadata; +import io.grpc.Status; +import io.grpc.xds.internal.headermutations.HeaderMutations.ResponseHeaderMutations; +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 +public abstract class AuthzResponse { + + /** Defines the authorization decision. */ + public enum Decision { + /** The request is permitted. */ + ALLOW, + /** The request is rejected. */ + DENY, + } + + /** Creates a builder for an ALLOW response, initializing with the specified headers. */ + public static Builder allow(Metadata headers) { + return new AutoValue_AuthzResponse.Builder().setDecision(Decision.ALLOW) + .setResponseHeaderMutations(ResponseHeaderMutations.create(ImmutableList.of())) + .setHeaders(headers); + } + + /** Creates a builder for a DENY response, initializing with the specified status. */ + public static Builder deny(Status status) { + return new AutoValue_AuthzResponse.Builder().setDecision(Decision.DENY) + .setResponseHeaderMutations(ResponseHeaderMutations.create(ImmutableList.of())) + .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(); + + /** + * For ALLOW decisions, this provides the headers to be appended to the request headers for + * upstream. It is empty for DENY decisions. + */ + public abstract Optional headers(); + + /** + * Returns mutations to be applied to the response headers. This is used for both ALLOW and DENY + * decisions. + */ + public abstract ResponseHeaderMutations responseHeaderMutations(); + + /** Builder for creating {@link AuthzResponse} instances. */ + @AutoValue.Builder + public abstract static class Builder { + + abstract Builder setDecision(Decision decision); + + abstract Builder setStatus(Status status); + + abstract Builder setHeaders(Metadata headers); + + public abstract Builder setResponseHeaderMutations( + ResponseHeaderMutations 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..6f03bcd1302 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extauthz/CheckResponseHandler.java @@ -0,0 +1,148 @@ +/* + * 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.headermutations.HeaderMutationDisallowedException; +import io.grpc.xds.internal.headermutations.HeaderMutationFilter; +import io.grpc.xds.internal.headermutations.HeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderMutator; + +/** + * Handles the response from the external authorization service, processing it to determine the + * authorization decision and applying any necessary header mutations. + */ +public interface CheckResponseHandler { + + /** + * A factory for creating {@link CheckResponseHandler} instances. + */ + @FunctionalInterface + interface Factory { + /** + * Creates a new ResponseHandler. + * + * @param headerMutator Utility to apply header mutations. + * @param headerMutationFilter Filter to apply to header mutations. + * @param config The external authorization configuration. + */ + CheckResponseHandler create(HeaderMutator headerMutator, + HeaderMutationFilter headerMutationFilter, ExtAuthzConfig config); + } + + /** + * The default factory for creating {@link CheckResponseHandler} instances. + */ + Factory INSTANCE = ResponseHandlerImpl::new; + + /** + * Processes the CheckResponse from the external authorization service. + * + * @param response The response from the authorization service. + * @param headers The request headers, which may be mutated as part of handling the response. + * @return An {@link AuthzResponse} indicating the outcome of the authorization check. + */ + AuthzResponse handleResponse(final CheckResponse response, Metadata headers); + + /** Default implementation of {@link CheckResponseHandler}. */ + static final class ResponseHandlerImpl implements CheckResponseHandler { + private final HeaderMutator headerMutator; + private final HeaderMutationFilter headerMutationFilter; + private final ExtAuthzConfig config; + + ResponseHandlerImpl(HeaderMutator headerMutator, // NOPMD + HeaderMutationFilter headerMutationFilter, ExtAuthzConfig config) { + this.headerMutator = headerMutator; + this.headerMutationFilter = headerMutationFilter; + this.config = config; + } + + @Override + public AuthzResponse handleResponse(final CheckResponse response, Metadata headers) { + try { + if (response.getStatus().getCode() == Status.Code.OK.value()) { + return handleOkResponse(response, headers); + } else { + return handleNotOkResponse(response); + } + } catch (HeaderMutationDisallowedException e) { + return AuthzResponse.deny(e.getStatus()).build(); + } + } + + private AuthzResponse handleOkResponse(final CheckResponse response, Metadata headers) + throws HeaderMutationDisallowedException { + if (!response.hasOkResponse()) { + return AuthzResponse.allow(headers).build(); + } + OkHttpResponse okResponse = response.getOkResponse(); + HeaderMutations requestedMutations = buildHeaderMutationsFromOkResponse(okResponse); + HeaderMutations allowedMutations = headerMutationFilter.filter(requestedMutations); + + applyMutations(allowedMutations, headers); + return AuthzResponse.allow(headers) + .setResponseHeaderMutations(allowedMutations.responseMutations()).build(); + } + + private HeaderMutations buildHeaderMutationsFromOkResponse(OkHttpResponse okResponse) { + return HeaderMutations.create( + HeaderMutations.RequestHeaderMutations.create( + ImmutableList.copyOf(okResponse.getHeadersList()), + ImmutableList.copyOf(okResponse.getHeadersToRemoveList())), + HeaderMutations.ResponseHeaderMutations + .create(ImmutableList.copyOf(okResponse.getResponseHeadersToAddList()))); + } + + private AuthzResponse handleNotOkResponse(CheckResponse response) + throws HeaderMutationDisallowedException { + Status statusToReturn = config.statusOnError(); + if (!response.hasDeniedResponse()) { + return AuthzResponse.deny(statusToReturn).build(); + } + DeniedHttpResponse deniedResponse = response.getDeniedResponse(); + HeaderMutations requestedMutations = buildHeaderMutationsFromDeniedResponse(deniedResponse); + HeaderMutations allowedMutations = headerMutationFilter.filter(requestedMutations); + + Status status = statusToReturn; + if (deniedResponse.hasStatus()) { + status = GrpcUtil.httpStatusToGrpcStatus(deniedResponse.getStatus().getCodeValue()) + .withDescription(deniedResponse.getBody()); + } + return AuthzResponse.deny(status) + .setResponseHeaderMutations(allowedMutations.responseMutations()).build(); + } + + private HeaderMutations buildHeaderMutationsFromDeniedResponse( + DeniedHttpResponse deniedResponse) { + return HeaderMutations.create( + HeaderMutations.RequestHeaderMutations.create(ImmutableList.of(), ImmutableList.of()), + HeaderMutations.ResponseHeaderMutations + .create(ImmutableList.copyOf(deniedResponse.getHeadersList()))); + } + + + private void applyMutations(final HeaderMutations mutations, Metadata headers) { + headerMutator.applyRequestMutations(mutations.requestMutations(), headers); + } + } +} 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..e81e356fe75 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/extauthz/AuthzResponseTest.java @@ -0,0 +1,66 @@ +/* + * 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.envoyproxy.envoy.config.core.v3.HeaderValue; +import io.envoyproxy.envoy.config.core.v3.HeaderValueOption; +import io.grpc.Metadata; +import io.grpc.Status; +import io.grpc.xds.internal.extauthz.AuthzResponse.Decision; +import io.grpc.xds.internal.headermutations.HeaderMutations.ResponseHeaderMutations; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class AuthzResponseTest { + @Test + public void testAllow() { + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("foo", Metadata.ASCII_STRING_MARSHALLER), "bar"); + AuthzResponse response = AuthzResponse.allow(headers).build(); + assertThat(response.decision()).isEqualTo(Decision.ALLOW); + assertThat(response.headers()).hasValue(headers); + assertThat(response.status()).isEmpty(); + assertThat(response.responseHeaderMutations().headers()).isEmpty(); + } + + @Test + public void testAllowWithHeaderMutations() { + Metadata headers = new Metadata(); + ResponseHeaderMutations mutations = + ResponseHeaderMutations.create(ImmutableList.of(HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder().setKey("key").setValue("value")).build())); + AuthzResponse response = + AuthzResponse.allow(headers).setResponseHeaderMutations(mutations).build(); + assertThat(response.decision()).isEqualTo(Decision.ALLOW); + assertThat(response.responseHeaderMutations()).isEqualTo(mutations); + } + + @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.headers()).isEmpty(); + assertThat(response.responseHeaderMutations().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..31b14a312c4 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/extauthz/CheckResponseHandlerTest.java @@ -0,0 +1,191 @@ +/* + * 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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableList; +import com.google.protobuf.Any; +import com.google.rpc.Code; +import io.envoyproxy.envoy.config.core.v3.HeaderValueOption; +import io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz; +import io.envoyproxy.envoy.extensions.grpc_service.call_credentials.access_token.v3.AccessTokenCredentials; +import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.google_default.v3.GoogleDefaultCredentials; +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.Metadata; +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.HeaderMutator; +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; + +@RunWith(JUnit4.class) +public class CheckResponseHandlerTest { + @Rule + public final MockitoRule mockitoRule = MockitoJUnit.rule(); + + @Mock + private HeaderMutator headerMutator; + @Mock + private HeaderMutationFilter headerMutationFilter; + + private CheckResponseHandler responseHandler; + + @Before + public void setUp() throws Exception { + responseHandler = + CheckResponseHandler.INSTANCE.create(headerMutator, headerMutationFilter, + buildExtAuthzConfig()); + 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(); + Metadata headers = new Metadata(); + AuthzResponse authzResponse = responseHandler.handleResponse(checkResponse, headers); + assertThat(authzResponse.decision()).isEqualTo(Decision.ALLOW); + assertThat(authzResponse.headers()).hasValue(headers); + } + + @Test + public void handleResponse_okWithMutations() { + HeaderValueOption option = HeaderValueOption.newBuilder().build(); + 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(); + Metadata headers = new Metadata(); + AuthzResponse authzResponse = responseHandler.handleResponse(checkResponse, headers); + assertThat(authzResponse.decision()).isEqualTo(Decision.ALLOW); + assertThat(authzResponse.headers()).hasValue(headers); + HeaderMutations expectedMutations = HeaderMutations.create( + HeaderMutations.RequestHeaderMutations.create(ImmutableList.of(option), + ImmutableList.of("remove-key")), + HeaderMutations.ResponseHeaderMutations.create(ImmutableList.of(option))); + verify(headerMutator).applyRequestMutations(expectedMutations.requestMutations(), headers); + assertThat(authzResponse.responseHeaderMutations()) + .isEqualTo(expectedMutations.responseMutations()); + } + + @Test + public void handleResponse_notOk() { + CheckResponse checkResponse = CheckResponse.newBuilder().setStatus(com.google.rpc.Status + .newBuilder().setCode(Code.PERMISSION_DENIED_VALUE).setMessage("denied").build()).build(); + Metadata headers = new Metadata(); + AuthzResponse authzResponse = responseHandler.handleResponse(checkResponse, headers); + 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("HTTP status code 403"); + verify(headerMutator, never()).applyRequestMutations(any(), any()); + } + + @Test + public void handleResponse_deniedResponseWithoutStatusOverride() { + HeaderValueOption option = HeaderValueOption.newBuilder().build(); + 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(); + Metadata headers = new Metadata(); + AuthzResponse authzResponse = responseHandler.handleResponse(checkResponse, headers); + assertThat(authzResponse.decision()).isEqualTo(Decision.DENY); + assertThat(authzResponse.status().get().getCode()) + .isEqualTo(Status.PERMISSION_DENIED.getCode()); + assertThat(authzResponse.status().get().getDescription()).isEqualTo("HTTP status code 403"); + HeaderMutations.ResponseHeaderMutations expectedMutations = + HeaderMutations.ResponseHeaderMutations.create(ImmutableList.of(option)); + assertThat(authzResponse.responseHeaderMutations()).isEqualTo(expectedMutations); + verify(headerMutator, never()).applyRequestMutations(any(), any()); + } + + @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(); + Metadata headers = new Metadata(); + AuthzResponse authzResponse = responseHandler.handleResponse(checkResponse, headers); + assertThat(authzResponse.decision()).isEqualTo(Decision.DENY); + assertThat(authzResponse.status().isPresent()).isTrue(); + Status status = authzResponse.status().get(); + assertThat(status.getCode()).isEqualTo(Status.Code.UNAUTHENTICATED); + assertThat(status.getDescription()).isEqualTo("custom body"); + HeaderMutations.ResponseHeaderMutations expectedMutations = + HeaderMutations.ResponseHeaderMutations.create(ImmutableList.of()); + assertThat(authzResponse.responseHeaderMutations()).isEqualTo(expectedMutations); + verify(headerMutator, never()).applyRequestMutations(any(), any()); + } + + @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(); + Metadata headers = new Metadata(); + HeaderMutationDisallowedException exception = + new HeaderMutationDisallowedException("disallowed"); + when(headerMutationFilter.filter(any(HeaderMutations.class))).thenThrow(exception); + + AuthzResponse authzResponse = responseHandler.handleResponse(checkResponse, headers); + + assertThat(authzResponse.decision()).isEqualTo(Decision.DENY); + assertThat(authzResponse.status().get().getCode()).isEqualTo(Status.INTERNAL.getCode()); + assertThat(authzResponse.status().get().getDescription()).isEqualTo("disallowed"); + } + + private ExtAuthzConfig buildExtAuthzConfig() throws ExtAuthzParseException { + Any googleDefaultChannelCreds = Any.pack(GoogleDefaultCredentials.newBuilder().build()); + Any fakeAccessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("fake-token").build()); + ExtAuthz extAuthz = ExtAuthz.newBuilder() + .setGrpcService(io.envoyproxy.envoy.config.core.v3.GrpcService.newBuilder() + .setGoogleGrpc(io.envoyproxy.envoy.config.core.v3.GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("test-cluster").addChannelCredentialsPlugin(googleDefaultChannelCreds) + .addCallCredentialsPlugin(fakeAccessTokenCreds).build()) + .build()) + .setStatusOnError( + io.envoyproxy.envoy.type.v3.HttpStatus.newBuilder().setCodeValue(403).build()) + .build(); + return ExtAuthzConfig.fromProto(extAuthz); + } +} From ff03a2a743d62d18b946e15d3090dee0bf486404 Mon Sep 17 00:00:00 2001 From: Saurav Date: Thu, 23 Oct 2025 10:26:01 +0000 Subject: [PATCH 6/8] feat(xds): Add ExtAuthzClientInterceptor and related components This commit introduces the client-side implementation of the external authorization filter. The main component is the `ExtAuthzClientInterceptor`, which intercepts outgoing RPCs and performs external authorization checks. It uses a `BufferingAuthzClientCall` to buffer the outgoing RPC until the authorization decision is received from the authorization service. The following new classes are introduced: - `ExtAuthzClientInterceptor`: The main client interceptor for external authorization. - `BufferingAuthzClientCall`: A `ClientCall` implementation that buffers requests until an authorization decision is made. - `CallBuffer`: A helper class for `BufferingAuthzClientCall` to manage the buffered calls. - `FailingClientCall`: A utility `ClientCall` that immediately fails, used when the filter is disabled and configured to deny calls. This commit also includes comprehensive unit and integration tests for the new components. --- .../io/grpc/internal/FailingClientCall.java | 57 ++ .../grpc/internal/FailingClientCallTest.java | 76 +++ .../java/io/grpc/xds/ThreadSafeRandom.java | 20 +- .../grpc/xds/internal/ThreadSafeRandom.java | 54 ++ .../extauthz/BufferingAuthzClientCall.java | 224 +++++++ .../xds/internal/extauthz/CallBuffer.java | 87 +++ .../extauthz/ExtAuthzClientInterceptor.java | 80 +++ .../BufferingAuthzClientCallTest.java | 611 ++++++++++++++++++ .../xds/internal/extauthz/CallBufferTest.java | 190 ++++++ .../ExtAuthzClientInterceptorTest.java | 175 +++++ 10 files changed, 1563 insertions(+), 11 deletions(-) create mode 100644 core/src/main/java/io/grpc/internal/FailingClientCall.java create mode 100644 core/src/test/java/io/grpc/internal/FailingClientCallTest.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/ThreadSafeRandom.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/extauthz/BufferingAuthzClientCall.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/extauthz/CallBuffer.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzClientInterceptor.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/extauthz/BufferingAuthzClientCallTest.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/extauthz/CallBufferTest.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzClientInterceptorTest.java diff --git a/core/src/main/java/io/grpc/internal/FailingClientCall.java b/core/src/main/java/io/grpc/internal/FailingClientCall.java new file mode 100644 index 00000000000..33c7012f09f --- /dev/null +++ b/core/src/main/java/io/grpc/internal/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.internal; + +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/core/src/test/java/io/grpc/internal/FailingClientCallTest.java b/core/src/test/java/io/grpc/internal/FailingClientCallTest.java new file mode 100644 index 00000000000..6fabfdd4b91 --- /dev/null +++ b/core/src/test/java/io/grpc/internal/FailingClientCallTest.java @@ -0,0 +1,76 @@ +/* + * Copyright 2016 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.internal; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import io.grpc.ClientCall; +import io.grpc.Metadata; +import io.grpc.Status; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +/** Unit tests for {@link FailingClientCall}. */ +@RunWith(JUnit4.class) +public class FailingClientCallTest { + + @Rule public final MockitoRule mocks = MockitoJUnit.rule(); + + @Mock + private ClientCall.Listener mockListener; + + @Test + public void startCallsOnClose() { + Status error = Status.UNAVAILABLE.withDescription("test error"); + FailingClientCall call = new FailingClientCall<>(error); + Metadata metadata = new Metadata(); + call.start(mockListener, metadata); + + ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(Metadata.class); + verify(mockListener).onClose(eq(error), metadataCaptor.capture()); + assertEquals(0, metadataCaptor.getValue().keys().size()); + verifyNoMoreInteractions(mockListener); + } + + @Test + public void otherMethodsAreNoOps() { + Status error = Status.UNAVAILABLE.withDescription("test error"); + FailingClientCall call = new FailingClientCall<>(error); + Metadata metadata = new Metadata(); + + call.start(mockListener, metadata); // Must call start first + + call.request(1); + call.cancel("message", new RuntimeException("cause")); + call.halfClose(); + call.sendMessage(new Object()); + + ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(Metadata.class); + verify(mockListener).onClose(eq(error), metadataCaptor.capture()); + assertEquals(0, metadataCaptor.getValue().keys().size()); + verifyNoMoreInteractions(mockListener); + } +} diff --git a/xds/src/main/java/io/grpc/xds/ThreadSafeRandom.java b/xds/src/main/java/io/grpc/xds/ThreadSafeRandom.java index 533ccee2375..87bd2ef1023 100644 --- a/xds/src/main/java/io/grpc/xds/ThreadSafeRandom.java +++ b/xds/src/main/java/io/grpc/xds/ThreadSafeRandom.java @@ -16,36 +16,34 @@ package io.grpc.xds; -import java.util.concurrent.ThreadLocalRandom; import javax.annotation.concurrent.ThreadSafe; -@ThreadSafe // Except for impls/mocks in tests -interface ThreadSafeRandom { - int nextInt(int bound); - - long nextLong(); - - long nextLong(long bound); +// TODO(sauravzg): Remove this class once all usages within xds are migrated to +// the internal version. +@ThreadSafe +interface ThreadSafeRandom extends io.grpc.xds.internal.ThreadSafeRandom { final class ThreadSafeRandomImpl implements ThreadSafeRandom { static final ThreadSafeRandom instance = new ThreadSafeRandomImpl(); + private final io.grpc.xds.internal.ThreadSafeRandom delegate = + io.grpc.xds.internal.ThreadSafeRandom.ThreadSafeRandomImpl.INSTANCE; private ThreadSafeRandomImpl() {} @Override public int nextInt(int bound) { - return ThreadLocalRandom.current().nextInt(bound); + return delegate.nextInt(bound); } @Override public long nextLong() { - return ThreadLocalRandom.current().nextLong(); + return delegate.nextLong(); } @Override public long nextLong(long bound) { - return ThreadLocalRandom.current().nextLong(bound); + return delegate.nextLong(bound); } } } diff --git a/xds/src/main/java/io/grpc/xds/internal/ThreadSafeRandom.java b/xds/src/main/java/io/grpc/xds/internal/ThreadSafeRandom.java new file mode 100644 index 00000000000..a51bfc8d6da --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/ThreadSafeRandom.java @@ -0,0 +1,54 @@ +/* + * Copyright 2023 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; + +import java.util.concurrent.ThreadLocalRandom; +import javax.annotation.concurrent.ThreadSafe; + +/** + * A thread-safe random number generator. This is intended for internal use only. + */ +@ThreadSafe // Except for impls/mocks in tests +public interface ThreadSafeRandom { + int nextInt(int bound); + + long nextLong(); + + long nextLong(long bound); + + final class ThreadSafeRandomImpl implements ThreadSafeRandom { + + public static final ThreadSafeRandom INSTANCE = new ThreadSafeRandomImpl(); + + private ThreadSafeRandomImpl() {} + + @Override + public int nextInt(int bound) { + return ThreadLocalRandom.current().nextInt(bound); + } + + @Override + public long nextLong() { + return ThreadLocalRandom.current().nextLong(); + } + + @Override + public long nextLong(long bound) { + return ThreadLocalRandom.current().nextLong(bound); + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/extauthz/BufferingAuthzClientCall.java b/xds/src/main/java/io/grpc/xds/internal/extauthz/BufferingAuthzClientCall.java new file mode 100644 index 00000000000..8cede96c897 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extauthz/BufferingAuthzClientCall.java @@ -0,0 +1,224 @@ +/* + * 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.protobuf.util.Timestamps; +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.Attributes; +import io.grpc.ClientCall; +import io.grpc.ForwardingClientCallListener; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.Status; +import io.grpc.stub.StreamObserver; +import io.grpc.xds.internal.headermutations.HeaderMutations.ResponseHeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderMutator; +import java.util.concurrent.atomic.AtomicBoolean; +import javax.annotation.Nullable; + +public final class BufferingAuthzClientCall extends ClientCall { + + private static final String X_ENVOY_AUTH_FAILURE_MODE_ALLOWED = + "x-envoy-auth-failure-mode-allowed"; + + /** A factory for creating {@link BufferingAuthzClientCall} instances. */ + @FunctionalInterface + public interface Factory { + ClientCall create(ClientCall delegate, + ExtAuthzConfig config, AuthorizationGrpc.AuthorizationStub authzStub, + CheckRequestBuilder checkRequestBuilder, CheckResponseHandler responseHandler, + HeaderMutator headerMutator, MethodDescriptor method, CallBuffer callBuffer); + } + + public static final Factory FACTORY_INSTANCE = BufferingAuthzClientCall::new; + + private final ClientCall delegate; + private final ExtAuthzConfig config; + private final MethodDescriptor method; + private final AuthorizationGrpc.AuthorizationStub authzStub; + private final CallBuffer callBuffer; + private final CheckRequestBuilder checkRequestBuilder; + private final CheckResponseHandler responseHandler; + private final HeaderMutator headerMutator; + private final AtomicBoolean callFailed = new AtomicBoolean(false); + + private BufferingAuthzClientCall(ClientCall delegate, ExtAuthzConfig config, + AuthorizationGrpc.AuthorizationStub authzStub, CheckRequestBuilder checkRequestBuilder, + CheckResponseHandler responseHandler, HeaderMutator headerMutator, + MethodDescriptor method, CallBuffer callBuffer) { + this.delegate = delegate; + this.config = config; + this.authzStub = authzStub; + this.checkRequestBuilder = checkRequestBuilder; + this.responseHandler = responseHandler; + this.headerMutator = headerMutator; + this.method = method; + this.callBuffer = callBuffer; + } + + private ClientCall delegate() { + return delegate; + } + + @Override + public boolean isReady() { + return callBuffer.isProcessed() && delegate.isReady(); + } + + + @Override + public void start(Listener responseListener, Metadata headers) { + // Headers is not thread-safe beyond `start`, so we need to create a copy to use in the async + // callback. + Metadata headersCopy = new Metadata(); + headersCopy.merge(headers); + StreamObserver observer = new StreamObserver() { + @Override + public void onNext(CheckResponse value) { + // This operation may mutate the headers + AuthzResponse authzResponse = responseHandler.handleResponse(value, headers); + if (authzResponse.decision() == AuthzResponse.Decision.ALLOW) { + // A allow response is guaranteed to have metadata set, so the `get` without + // check is safe. + delegate.start( + HeaderMutatingClientCallListener.create(responseListener, + authzResponse.responseHeaderMutations(), headerMutator), + authzResponse.headers().get()); + callBuffer.runAndFlush(); + } else { + // A deny response is guaranteed to have a status set, so the `get` without + // check is safe. + failUnstartedCall(authzResponse.status().get(), new Metadata(), responseListener); + } + } + + @Override + public void onError(Throwable t) { + // If failureModeAllow is true, bypass the authorization failure + if (config.failureModeAllow()) { + if (config.failureModeAllowHeaderAdd()) { + Metadata.Key failureModeKey = Metadata.Key.of(X_ENVOY_AUTH_FAILURE_MODE_ALLOWED, + Metadata.ASCII_STRING_MARSHALLER); + headersCopy.put(failureModeKey, "true"); + } + delegate.start(responseListener, headersCopy); + callBuffer.runAndFlush(); + } else { + // Authorization failed and failureModeAllow is false + Status statusToReturn = config.statusOnError().withCause(t); + failUnstartedCall(statusToReturn, new Metadata(), responseListener); + } + } + + @Override + public void onCompleted() { + // no-op, since this is a unary API. + } + }; + CheckRequest request = checkRequestBuilder.buildRequest(method, headers, + Timestamps.fromMillis(System.currentTimeMillis())); + authzStub.check(request, observer); + } + + @Override + public void request(int numMessages) { + runOrBuffer(() -> delegate().request(numMessages)); + } + + @Override + public void cancel(@Nullable String message, @Nullable Throwable cause) { + delegate().cancel(message, cause); + callBuffer.abandon(); + } + + @Override + public void halfClose() { + runOrBuffer(() -> delegate().halfClose()); + } + + @Override + public void sendMessage(ReqT message) { + runOrBuffer(() -> delegate().sendMessage(message)); + } + + @Override + public void setMessageCompression(boolean enabled) { + runOrBuffer(() -> delegate().setMessageCompression(enabled)); + } + + @Override + public Attributes getAttributes() { + // Since returning attributes can't be buffered and no other method except `cancel` can be + // called on the delegated object until it's started,we will have to unfortunately return empty + // until we are sure that `start` had been called. + if (!callBuffer.isProcessed() || callFailed.get()) { + return Attributes.EMPTY; + } else { + return delegate.getAttributes(); + } + } + + private void runOrBuffer(Runnable runnable) { + if (callFailed.get()) { + return; + } + if (callBuffer.isProcessed()) { + runnable.run(); + } else { + callBuffer.runOrBuffer(runnable); + } + } + + private void failUnstartedCall(Status status, Metadata trailers, + Listener responseListener) { + callFailed.set(true); + responseListener.onClose(status, trailers); + callBuffer.abandon(); + } + + /** + * A {@link ForwardingClientCallListener} that mutates the response headers before passing them to + * the delegate. + */ + private static final class HeaderMutatingClientCallListener + extends ForwardingClientCallListener.SimpleForwardingClientCallListener { + + private final ResponseHeaderMutations responseHeaderMutations; + private final HeaderMutator headerMutator; + + static ClientCall.Listener create(ClientCall.Listener delegate, + ResponseHeaderMutations responseHeaderMutations, HeaderMutator headerMutator) { + return new HeaderMutatingClientCallListener<>(delegate, responseHeaderMutations, + headerMutator); + } + + private HeaderMutatingClientCallListener(ClientCall.Listener delegate, + ResponseHeaderMutations responseHeaderMutations, HeaderMutator headerMutator) { + super(delegate); + this.responseHeaderMutations = responseHeaderMutations; + this.headerMutator = headerMutator; + } + + @Override + public void onHeaders(Metadata headers) { + headerMutator.applyResponseMutations(responseHeaderMutations, headers); + super.onHeaders(headers); + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/extauthz/CallBuffer.java b/xds/src/main/java/io/grpc/xds/internal/extauthz/CallBuffer.java new file mode 100644 index 00000000000..8732b4c522d --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extauthz/CallBuffer.java @@ -0,0 +1,87 @@ +/* + * 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 java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import javax.annotation.concurrent.ThreadSafe; + +/** + * A buffer for client calls that are pending an authorization decision. + */ +@ThreadSafe +final class CallBuffer { + + private final AtomicBoolean processed = new AtomicBoolean(false); + private final List bufferedCalls = new ArrayList<>(); + private final Object lock = new Object(); + + /** + * Buffers a runnable to be executed later. If the buffer has already been processed, the + * runnable is executed immediately. + * + * @param runnable the runnable to buffer. + */ + public void runOrBuffer(Runnable runnable) { + synchronized (lock) { + if (processed.get()) { + runnable.run(); + } else { + bufferedCalls.add(runnable); + } + } + } + + /** + * Executes all buffered runnables and marks the buffer as processed. + */ + public void runAndFlush() { + List toRun; + synchronized (lock) { + if (processed.getAndSet(true)) { + return; + } + toRun = new ArrayList<>(bufferedCalls); + bufferedCalls.clear(); + } + for (Runnable runnable : toRun) { + runnable.run(); + } + } + + /** + * Abandons all buffered runnables and marks the buffer as processed. + */ + public void abandon() { + synchronized (lock) { + if (processed.getAndSet(true)) { + return; + } + bufferedCalls.clear(); + } + } + + /** + * Returns whether the buffer has been processed. + * + * @return true if the buffer has been processed, false otherwise. + */ + public boolean isProcessed() { + return processed.get(); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzClientInterceptor.java b/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzClientInterceptor.java new file mode 100644 index 00000000000..5e982b5c29f --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzClientInterceptor.java @@ -0,0 +1,80 @@ +/* + * 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.errorprone.annotations.ThreadSafe; +import io.envoyproxy.envoy.service.auth.v3.AuthorizationGrpc; +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptor; +import io.grpc.MethodDescriptor; +import io.grpc.internal.FailingClientCall; +import io.grpc.xds.internal.Matchers.FractionMatcher; +import io.grpc.xds.internal.ThreadSafeRandom; +import io.grpc.xds.internal.headermutations.HeaderMutator; + +@ThreadSafe +public final class ExtAuthzClientInterceptor implements ClientInterceptor { + + /** A factory for creating {@link ExtAuthzClientInterceptor} instances. */ + @FunctionalInterface + public interface Factory { + ClientInterceptor create(ExtAuthzConfig config, AuthorizationGrpc.AuthorizationStub authzStub, + ThreadSafeRandom random, BufferingAuthzClientCall.Factory clientCallFactory, + CheckRequestBuilder checkRequestBuilder, CheckResponseHandler responseHandler, + HeaderMutator headerMutator); + } + + public static final Factory INSTANCE = ExtAuthzClientInterceptor::new; + + private final ExtAuthzConfig config; + private final AuthorizationGrpc.AuthorizationStub authzStub; + private final ThreadSafeRandom random; + private final BufferingAuthzClientCall.Factory clientCallFactory; + private final CheckRequestBuilder checkRequestBuilder; + private final CheckResponseHandler responseHandler; + private final HeaderMutator headerMutator; + + + private ExtAuthzClientInterceptor(ExtAuthzConfig config, + AuthorizationGrpc.AuthorizationStub authzStub, ThreadSafeRandom random, + BufferingAuthzClientCall.Factory clientCallFactory, CheckRequestBuilder checkRequestBuilder, + CheckResponseHandler responseHandler, HeaderMutator headerMutator) { + this.config = config; + this.random = random; + this.authzStub = authzStub; + this.clientCallFactory = clientCallFactory; + this.checkRequestBuilder = checkRequestBuilder; + this.responseHandler = responseHandler; + this.headerMutator = headerMutator; + } + + @Override + public ClientCall interceptCall(MethodDescriptor method, + CallOptions callOptions, Channel next) { + FractionMatcher filterEnabled = config.filterEnabled(); + if (random.nextInt(filterEnabled.denominator()) < filterEnabled.numerator()) { + if (config.denyAtDisable()) { + return new FailingClientCall<>(config.statusOnError()); + } + return next.newCall(method, callOptions); + } + return clientCallFactory.create(next.newCall(method, callOptions), config, authzStub, + checkRequestBuilder, responseHandler, headerMutator, method, new CallBuffer()); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/extauthz/BufferingAuthzClientCallTest.java b/xds/src/test/java/io/grpc/xds/internal/extauthz/BufferingAuthzClientCallTest.java new file mode 100644 index 00000000000..2704d18b4f3 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/extauthz/BufferingAuthzClientCallTest.java @@ -0,0 +1,611 @@ +/* + * 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.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableList; +import com.google.protobuf.Any; +import com.google.protobuf.Timestamp; +import io.envoyproxy.envoy.config.core.v3.GrpcService; +import io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz; +import io.envoyproxy.envoy.extensions.grpc_service.call_credentials.access_token.v3.AccessTokenCredentials; +import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.google_default.v3.GoogleDefaultCredentials; +import io.envoyproxy.envoy.service.auth.v3.AttributeContext; +import io.envoyproxy.envoy.service.auth.v3.AttributeContext.HttpRequest; +import io.envoyproxy.envoy.service.auth.v3.AttributeContext.Request; +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.OkHttpResponse; +import io.grpc.CallOptions; +import io.grpc.ClientCall; +import io.grpc.ManagedChannel; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.Server; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.ServerInterceptors; +import io.grpc.Status; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; +import io.grpc.stub.ClientCalls; +import io.grpc.stub.MetadataUtils; +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.internal.headermutations.HeaderMutations.ResponseHeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderMutator; +import java.io.IOException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +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; +import org.mockito.stubbing.Answer; + +/** Unit tests for {@link BufferingAuthzClientCall}. */ +@RunWith(JUnit4.class) +public class BufferingAuthzClientCallTest { + + @Rule + public final MockitoRule mockito = MockitoJUnit.rule(); + + @Mock + private AuthorizationGrpc.AuthorizationImplBase authzService; + @Mock + private CheckRequestBuilder checkRequestBuilder; + @Mock + private CheckResponseHandler responseHandler; + @Mock + private HeaderMutator headerMutator; + + private ManagedChannel channel; + private Server server; + + private final AtomicReference serverHeadersCapture = new AtomicReference<>(); + private final AtomicReference clientHeadersCapture = new AtomicReference<>(); + private final AtomicReference clientTrailersCapture = new AtomicReference<>(); + + + @Before + public void setUp() throws IOException { + server = InProcessServerBuilder + .forName("in-process-server").addService(authzService).addService(ServerInterceptors + .intercept(new SimpleServiceImpl(), + new MetadataCapturingServerInterceptor(serverHeadersCapture))) + .directExecutor() + .build().start(); + channel = + InProcessChannelBuilder + .forName("in-process-server").intercept(MetadataUtils + .newCaptureMetadataInterceptor(clientHeadersCapture, clientTrailersCapture)) + .directExecutor() + .build(); + } + + @After + public void tearDown() { + server.shutdownNow(); + channel.shutdownNow(); + } + + @Test + public void onUnary_allowresponse() throws InterruptedException, ExtAuthzParseException { + ExtAuthzConfig config = buildExtAuthzConfig(false, false, 403); + + CheckRequest checkRequest = CheckRequest.newBuilder().setAttributes(AttributeContext + .newBuilder() + .setRequest(Request.newBuilder() + .setHttp(HttpRequest.newBuilder().setId("RequestId").build()).build()) + .build()) + .build(); + when(checkRequestBuilder.buildRequest(eq(SimpleServiceGrpc.getUnaryRpcMethod()), + any(Metadata.class), any(Timestamp.class))).thenReturn(checkRequest); + + CheckResponse checkResponse = CheckResponse.newBuilder() + .setStatus(com.google.rpc.Status.newBuilder().setCode(Status.Code.OK.value()).build()) + .setOkResponse(OkHttpResponse.getDefaultInstance()).build(); + doAnswer((Answer) invocation -> { + StreamObserver observer = invocation.getArgument(1); + observer.onNext(checkResponse); + observer.onCompleted(); + return null; + }).when(authzService).check(eq(checkRequest), + ArgumentMatchers.>any()); + + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("key1", Metadata.ASCII_STRING_MARSHALLER), "value1"); + ResponseHeaderMutations responseHeaderMutations = + ResponseHeaderMutations.create(ImmutableList.of()); + AuthzResponse allowResponse = + AuthzResponse.allow(metadata) + .setResponseHeaderMutations(responseHeaderMutations).build(); + when(responseHandler.handleResponse(eq(checkResponse), any(Metadata.class))) + .thenReturn(allowResponse); + + doAnswer((Answer) invocation -> { + Metadata headers = invocation.getArgument(1); + headers.put(Metadata.Key.of("key2", Metadata.ASCII_STRING_MARSHALLER), "value2"); + return null; + }).when(headerMutator).applyResponseMutations(eq(responseHeaderMutations), + any(Metadata.class)); + + ClientCall realCall = + channel.newCall(SimpleServiceGrpc.getUnaryRpcMethod(), CallOptions.DEFAULT); + AuthorizationGrpc.AuthorizationStub authzStub = AuthorizationGrpc.newStub(channel); + ClientCall call = BufferingAuthzClientCall.FACTORY_INSTANCE + .create(realCall, config, authzStub, checkRequestBuilder, responseHandler, headerMutator, + SimpleServiceGrpc.getUnaryRpcMethod(), new CallBuffer()); + SimpleServiceUnaryResponseObserver simpleServiceResponseObserver = + new SimpleServiceUnaryResponseObserver(); + SimpleRequest simpleRequest = SimpleRequest.newBuilder().setRequestMessage("World").build(); + ClientCalls.asyncUnaryCall(call, simpleRequest, simpleServiceResponseObserver); + simpleServiceResponseObserver.await(); + + assertThat(simpleServiceResponseObserver.getResponse().getResponseMessage()) + .isEqualTo("Hello World"); + assertThat( + serverHeadersCapture.get().get(Metadata.Key.of("key1", Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("value1"); + assertThat( + clientHeadersCapture.get().get(Metadata.Key.of("key2", Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("value2"); + assertThat(clientTrailersCapture.get()).isNotNull(); + verify(headerMutator).applyResponseMutations(eq(responseHeaderMutations), any(Metadata.class)); + } + + @Test + public void onUnary_denyResponse() throws InterruptedException, ExtAuthzParseException { + ExtAuthzConfig config = buildExtAuthzConfig(false, false, 403); + CheckRequest checkRequest = CheckRequest.newBuilder().build(); + when(checkRequestBuilder.buildRequest(eq(SimpleServiceGrpc.getUnaryRpcMethod()), + any(Metadata.class), any(Timestamp.class))).thenReturn(checkRequest); + + CheckResponse checkResponse = CheckResponse.newBuilder().setStatus( + com.google.rpc.Status.newBuilder().setCode(Status.Code.PERMISSION_DENIED.value()).build()) + .build(); + doAnswer((Answer) invocation -> { + StreamObserver observer = invocation.getArgument(1); + observer.onNext(checkResponse); + observer.onCompleted(); + return null; + }).when(authzService).check(eq(checkRequest), + ArgumentMatchers.>any()); + + Status expectedStatus = Status.PERMISSION_DENIED.withDescription("ext authz denied"); + AuthzResponse denyResponse = AuthzResponse.deny(expectedStatus).build(); + when(responseHandler.handleResponse(eq(checkResponse), any(Metadata.class))) + .thenReturn(denyResponse); + + ClientCall realCall = + channel.newCall(SimpleServiceGrpc.getUnaryRpcMethod(), CallOptions.DEFAULT); + AuthorizationGrpc.AuthorizationStub authzStub = AuthorizationGrpc.newStub(channel); + CallBuffer callBuffer = new CallBuffer(); + ClientCall call = BufferingAuthzClientCall.FACTORY_INSTANCE + .create(realCall, config, authzStub, checkRequestBuilder, responseHandler, headerMutator, + SimpleServiceGrpc.getUnaryRpcMethod(), callBuffer); + SimpleServiceUnaryResponseObserver simpleServiceResponseObserver = + new SimpleServiceUnaryResponseObserver(); + SimpleRequest simpleRequest = SimpleRequest.newBuilder().setRequestMessage("World").build(); + ClientCalls.asyncUnaryCall(call, + simpleRequest, simpleServiceResponseObserver); + simpleServiceResponseObserver.await(); + + assertThat(simpleServiceResponseObserver.getResponse()).isNull(); + assertThat(simpleServiceResponseObserver.getError()).isNotNull(); + assertThat(simpleServiceResponseObserver.getError().getCode()) + .isEqualTo(expectedStatus.getCode()); + assertThat(simpleServiceResponseObserver.getError().getDescription()) + .isEqualTo(expectedStatus.getDescription()); + assertThat(callBuffer.isProcessed()).isTrue(); + } + + @Test + public void onUnary_authzServerError_failTheCall() + throws InterruptedException, ExtAuthzParseException { + CheckRequest checkRequest = CheckRequest.newBuilder().build(); + when(checkRequestBuilder.buildRequest(eq(SimpleServiceGrpc.getUnaryRpcMethod()), + any(Metadata.class), any(Timestamp.class))).thenReturn(checkRequest); + + Status authzError = Status.UNAVAILABLE.withDescription("ext authz server unavailable"); + doAnswer((Answer) invocation -> { + StreamObserver observer = invocation.getArgument(1); + observer.onError(authzError.asRuntimeException()); + return null; + }).when(authzService).check(eq(checkRequest), + ArgumentMatchers.>any()); + + ExtAuthzConfig config = buildExtAuthzConfig(false, false, 503); + + + ClientCall realCall = + channel.newCall(SimpleServiceGrpc.getUnaryRpcMethod(), CallOptions.DEFAULT); + AuthorizationGrpc.AuthorizationStub authzStub = AuthorizationGrpc.newStub(channel); + CallBuffer callBuffer = new CallBuffer(); + ClientCall call = BufferingAuthzClientCall.FACTORY_INSTANCE + .create(realCall, config, authzStub, checkRequestBuilder, responseHandler, headerMutator, + SimpleServiceGrpc.getUnaryRpcMethod(), callBuffer); + SimpleServiceUnaryResponseObserver simpleServiceResponseObserver = + new SimpleServiceUnaryResponseObserver(); + SimpleRequest simpleRequest = SimpleRequest.newBuilder().setRequestMessage("World").build(); + ClientCalls.asyncUnaryCall(call, simpleRequest, simpleServiceResponseObserver); + simpleServiceResponseObserver.await(); + + assertThat(simpleServiceResponseObserver.getResponse()).isNull(); + assertThat(simpleServiceResponseObserver.getError()).isNotNull(); + assertThat(simpleServiceResponseObserver.getError().getCode()) + .isEqualTo(Status.Code.UNAVAILABLE); + assertThat(callBuffer.isProcessed()).isTrue(); + verify(responseHandler, never()).handleResponse(any(CheckResponse.class), any(Metadata.class)); + } + + @Test + public void onUnary_authzServerError_failureModeAllow() + throws InterruptedException, ExtAuthzParseException { + ExtAuthzConfig config = buildExtAuthzConfig(true, false, 503); + CheckRequest checkRequest = CheckRequest.newBuilder().build(); + when(checkRequestBuilder.buildRequest(eq(SimpleServiceGrpc.getUnaryRpcMethod()), + any(Metadata.class), any(Timestamp.class))).thenReturn(checkRequest); + + Status authzError = Status.UNAVAILABLE.withDescription("authz server unavailable"); + doAnswer((Answer) invocation -> { + StreamObserver observer = invocation.getArgument(1); + observer.onError(authzError.asRuntimeException()); + return null; + }).when(authzService).check(eq(checkRequest), + ArgumentMatchers.>any()); + + ClientCall realCall = + channel.newCall(SimpleServiceGrpc.getUnaryRpcMethod(), CallOptions.DEFAULT); + AuthorizationGrpc.AuthorizationStub authzStub = AuthorizationGrpc.newStub(channel); + ClientCall call = BufferingAuthzClientCall.FACTORY_INSTANCE + .create(realCall, config, authzStub, checkRequestBuilder, responseHandler, headerMutator, + SimpleServiceGrpc.getUnaryRpcMethod(), new CallBuffer()); + SimpleServiceUnaryResponseObserver simpleServiceResponseObserver = + new SimpleServiceUnaryResponseObserver(); + SimpleRequest simpleRequest = SimpleRequest.newBuilder().setRequestMessage("World").build(); + ClientCalls.asyncUnaryCall(call, simpleRequest, simpleServiceResponseObserver); + simpleServiceResponseObserver.await(); + + assertThat(simpleServiceResponseObserver.getResponse().getResponseMessage()) + .isEqualTo("Hello World"); + assertThat( + serverHeadersCapture.get().get(Metadata.Key.of("key1", Metadata.ASCII_STRING_MARSHALLER))) + .isNull(); + assertThat( + clientHeadersCapture.get().get(Metadata.Key.of("key2", Metadata.ASCII_STRING_MARSHALLER))) + .isNull(); + assertThat(clientTrailersCapture.get()).isNotNull(); + verify(headerMutator, never()).applyResponseMutations(any(ResponseHeaderMutations.class), + any(Metadata.class)); + verify(responseHandler, never()).handleResponse(any(CheckResponse.class), any(Metadata.class)); + } + + @Test + public void onUnary_authzServerError_failureModeAllowHeaderAdd() + throws InterruptedException, ExtAuthzParseException { + ExtAuthzConfig config = buildExtAuthzConfig(true, true, 503); + CheckRequest checkRequest = CheckRequest.newBuilder().build(); + when(checkRequestBuilder.buildRequest(eq(SimpleServiceGrpc.getUnaryRpcMethod()), + any(Metadata.class), any(Timestamp.class))).thenReturn(checkRequest); + + Status authzError = Status.UNAVAILABLE.withDescription("authz server unavailable"); + doAnswer((Answer) invocation -> { + StreamObserver observer = invocation.getArgument(1); + observer.onError(authzError.asRuntimeException()); + return null; + }).when(authzService).check(eq(checkRequest), + ArgumentMatchers.>any()); + + ClientCall realCall = + channel.newCall(SimpleServiceGrpc.getUnaryRpcMethod(), CallOptions.DEFAULT); + AuthorizationGrpc.AuthorizationStub authzStub = AuthorizationGrpc.newStub(channel); + ClientCall call = BufferingAuthzClientCall.FACTORY_INSTANCE + .create(realCall, config, authzStub, checkRequestBuilder, responseHandler, headerMutator, + SimpleServiceGrpc.getUnaryRpcMethod(), new CallBuffer()); + SimpleServiceUnaryResponseObserver simpleServiceResponseObserver = + new SimpleServiceUnaryResponseObserver(); + SimpleRequest simpleRequest = SimpleRequest.newBuilder().setRequestMessage("World").build(); + ClientCalls.asyncUnaryCall(call, simpleRequest, simpleServiceResponseObserver); + simpleServiceResponseObserver.await(); + + assertThat(simpleServiceResponseObserver.getResponse().getResponseMessage()) + .isEqualTo("Hello World"); + assertThat(serverHeadersCapture.get().get( + Metadata.Key.of("x-envoy-auth-failure-mode-allowed", Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("true"); + assertThat(clientTrailersCapture.get()).isNotNull(); + verify(headerMutator, never()).applyResponseMutations(any(ResponseHeaderMutations.class), + any(Metadata.class)); + verify(responseHandler, never()).handleResponse(any(CheckResponse.class), any(Metadata.class)); + } + + @Test + public void onStreaming_allowResponse() throws InterruptedException, ExtAuthzParseException { + ExtAuthzConfig config = buildExtAuthzConfig(false, false, 403); + MethodDescriptor streamingMethod = + SimpleServiceGrpc.getBidiStreamingRpcMethod(); + CheckRequest checkRequest = CheckRequest.newBuilder() + .setAttributes(AttributeContext.newBuilder() + .setRequest(Request.newBuilder() + .setHttp(HttpRequest.newBuilder().setId("RequestId").build()).build()) + .build()) + .build(); + when(checkRequestBuilder.buildRequest(eq(streamingMethod), any(Metadata.class), + any(Timestamp.class))).thenReturn(checkRequest); + + CheckResponse checkResponse = CheckResponse.newBuilder() + .setStatus(com.google.rpc.Status.newBuilder().setCode(Status.Code.OK.value()).build()) + .setOkResponse(OkHttpResponse.getDefaultInstance()).build(); + doAnswer((Answer) invocation -> { + StreamObserver observer = invocation.getArgument(1); + observer.onNext(checkResponse); + observer.onCompleted(); + return null; + }).when(authzService).check(eq(checkRequest), + ArgumentMatchers.>any()); + + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("key1", Metadata.ASCII_STRING_MARSHALLER), "value1"); + ResponseHeaderMutations responseHeaderMutations = + ResponseHeaderMutations.create(ImmutableList.of()); + AuthzResponse allowResponse = + AuthzResponse.allow(metadata).setResponseHeaderMutations(responseHeaderMutations).build(); + when(responseHandler.handleResponse(eq(checkResponse), any(Metadata.class))) + .thenReturn(allowResponse); + + doAnswer((Answer) invocation -> { + Metadata headers = invocation.getArgument(1); + headers.put(Metadata.Key.of("key2", Metadata.ASCII_STRING_MARSHALLER), "value2"); + return null; + }).when(headerMutator).applyResponseMutations(eq(responseHeaderMutations), any(Metadata.class)); + + ClientCall realCall = + channel.newCall(streamingMethod, CallOptions.DEFAULT); + AuthorizationGrpc.AuthorizationStub authzStub = AuthorizationGrpc.newStub(channel); + ClientCall call = + BufferingAuthzClientCall.FACTORY_INSTANCE.create(realCall, config, authzStub, + checkRequestBuilder, responseHandler, headerMutator, streamingMethod, new CallBuffer()); + SimpleServiceStreamingResponseObserver simpleServiceResponseObserver = + new SimpleServiceStreamingResponseObserver(); + StreamObserver requestObserver = + ClientCalls.asyncBidiStreamingCall(call, simpleServiceResponseObserver); + requestObserver.onNext(SimpleRequest.newBuilder().setRequestMessage("World").build()); + requestObserver.onNext(SimpleRequest.newBuilder().setRequestMessage("gRPC").build()); + requestObserver.onCompleted(); + simpleServiceResponseObserver.await(); + + assertThat(simpleServiceResponseObserver.getResponses()) + .containsExactly(SimpleResponse.newBuilder().setResponseMessage("Hello World").build(), + SimpleResponse.newBuilder().setResponseMessage("Hello gRPC").build()) + .inOrder(); + assertThat( + serverHeadersCapture.get().get(Metadata.Key.of("key1", Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("value1"); + assertThat( + clientHeadersCapture.get().get(Metadata.Key.of("key2", Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("value2"); + assertThat(clientTrailersCapture.get()).isNotNull(); + verify(headerMutator).applyResponseMutations(eq(responseHeaderMutations), any(Metadata.class)); + } + + @Test + public void onStreaming_denyResponse() throws InterruptedException, ExtAuthzParseException { + ExtAuthzConfig config = buildExtAuthzConfig(false, false, 403); + MethodDescriptor streamingMethod = + SimpleServiceGrpc.getBidiStreamingRpcMethod(); + CheckRequest checkRequest = CheckRequest.newBuilder().build(); + when(checkRequestBuilder.buildRequest(eq(streamingMethod), any(Metadata.class), + any(Timestamp.class))).thenReturn(checkRequest); + + CheckResponse checkResponse = CheckResponse.newBuilder().setStatus( + com.google.rpc.Status.newBuilder().setCode(Status.Code.PERMISSION_DENIED.value()).build()) + .build(); + doAnswer((Answer) invocation -> { + StreamObserver observer = invocation.getArgument(1); + observer.onNext(checkResponse); + observer.onCompleted(); + return null; + }).when(authzService).check(eq(checkRequest), + ArgumentMatchers.>any()); + + Status expectedStatus = Status.PERMISSION_DENIED.withDescription("ext authz denied"); + AuthzResponse denyResponse = AuthzResponse.deny(expectedStatus).build(); + when(responseHandler.handleResponse(eq(checkResponse), any(Metadata.class))) + .thenReturn(denyResponse); + + ClientCall realCall = + channel.newCall(streamingMethod, CallOptions.DEFAULT); + AuthorizationGrpc.AuthorizationStub authzStub = AuthorizationGrpc.newStub(channel); + CallBuffer callBuffer = new CallBuffer(); + ClientCall call = + BufferingAuthzClientCall.FACTORY_INSTANCE.create(realCall, config, authzStub, + checkRequestBuilder, responseHandler, headerMutator, streamingMethod, callBuffer); + SimpleServiceStreamingResponseObserver simpleServiceResponseObserver = + new SimpleServiceStreamingResponseObserver(); + StreamObserver requestObserver = + ClientCalls.asyncBidiStreamingCall(call, simpleServiceResponseObserver); + requestObserver.onNext(SimpleRequest.newBuilder().setRequestMessage("World").build()); + requestObserver.onNext(SimpleRequest.newBuilder().setRequestMessage("gRPC").build()); + requestObserver.onCompleted(); + simpleServiceResponseObserver.await(); + + assertThat(simpleServiceResponseObserver.getResponses()).isEmpty(); + assertThat(simpleServiceResponseObserver.getError()).isNotNull(); + assertThat(simpleServiceResponseObserver.getError().getCode()) + .isEqualTo(expectedStatus.getCode()); + assertThat(simpleServiceResponseObserver.getError().getDescription()) + .isEqualTo(expectedStatus.getDescription()); + assertThat(callBuffer.isProcessed()).isTrue(); + } + + private ExtAuthzConfig buildExtAuthzConfig(boolean failureModeAllow, + boolean failureModeAllowHeaderAdd, int httpStatusOnError) throws ExtAuthzParseException { + Any googleDefaultChannelCreds = Any.pack(GoogleDefaultCredentials.newBuilder().build()); + Any fakeAccessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("fake-token").build()); + ExtAuthz.Builder builder = ExtAuthz.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("test-cluster").addChannelCredentialsPlugin(googleDefaultChannelCreds) + .addCallCredentialsPlugin(fakeAccessTokenCreds).build()) + .build()) + .setFailureModeAllow(failureModeAllow) + .setFailureModeAllowHeaderAdd(failureModeAllowHeaderAdd) + .setStatusOnError(io.envoyproxy.envoy.type.v3.HttpStatus.newBuilder() + .setCodeValue(httpStatusOnError).build()) + .setIncludePeerCertificate(true); + return ExtAuthzConfig.fromProto(builder.build()); + } + + private static class SimpleServiceUnaryResponseObserver + implements StreamObserver { + final AtomicReference responseCapture = new AtomicReference<>(); + final AtomicReference errorCapture = new AtomicReference<>(); + final CountDownLatch latch = new CountDownLatch(1); + + @Override + public void onNext(SimpleResponse value) { + responseCapture.set(value); + } + + @Override + public void onError(Throwable t) { + errorCapture.set(Status.fromThrowable(t)); + latch.countDown(); + } + + @Override + public void onCompleted() { + latch.countDown(); + } + + public void await() throws InterruptedException { + latch.await(5, TimeUnit.SECONDS); + } + + public SimpleResponse getResponse() { + return responseCapture.get(); + } + + public Status getError() { + return errorCapture.get(); + } + } + + private static class SimpleServiceStreamingResponseObserver + implements StreamObserver { + final ImmutableList.Builder responsesCapture = new ImmutableList.Builder<>(); + final AtomicReference errorCapture = new AtomicReference<>(); + final CountDownLatch latch = new CountDownLatch(1); + + @Override + public void onNext(SimpleResponse value) { + responsesCapture.add(value); + } + + @Override + public void onError(Throwable t) { + errorCapture.set(Status.fromThrowable(t)); + latch.countDown(); + } + + @Override + public void onCompleted() { + latch.countDown(); + } + + public void await() throws InterruptedException { + latch.await(5, TimeUnit.SECONDS); + } + + public ImmutableList getResponses() { + return responsesCapture.build(); + } + + public Status getError() { + return errorCapture.get(); + } + } + + private static final class MetadataCapturingServerInterceptor implements ServerInterceptor { + + final AtomicReference headersCapture; + + MetadataCapturingServerInterceptor(AtomicReference headersCapture) { + this.headersCapture = headersCapture; + } + + @Override + public ServerCall.Listener interceptCall(ServerCall call, + Metadata headers, ServerCallHandler next) { + Metadata metadataCopy = new Metadata(); + metadataCopy.merge(headers); + headersCapture.set(metadataCopy); + return next.startCall(call, headers); + } + } + + private static class SimpleServiceImpl extends SimpleServiceGrpc.SimpleServiceImplBase { + @Override + public void unaryRpc(SimpleRequest request, StreamObserver streamObserver) { + streamObserver.onNext(SimpleResponse.newBuilder() + .setResponseMessage("Hello " + request.getRequestMessage()).build()); + streamObserver.onCompleted(); + } + + @Override + public StreamObserver bidiStreamingRpc( + final StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(SimpleRequest request) { + responseObserver.onNext(SimpleResponse.newBuilder() + .setResponseMessage("Hello " + request.getRequestMessage()).build()); + } + + @Override + public void onError(Throwable t) { + responseObserver.onError(t); + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/extauthz/CallBufferTest.java b/xds/src/test/java/io/grpc/xds/internal/extauthz/CallBufferTest.java new file mode 100644 index 00000000000..15baf213be8 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/extauthz/CallBufferTest.java @@ -0,0 +1,190 @@ +/* + * 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.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.InOrder; + +@RunWith(JUnit4.class) +public class CallBufferTest { + + private CallBuffer callBuffer; + + @Before + public void setUp() { + callBuffer = new CallBuffer(); + } + + @Test + public void runOrBuffer_beforeProcessed_buffersCall() { + Runnable runnable = mock(Runnable.class); + callBuffer.runOrBuffer(runnable); + verify(runnable, never()).run(); + assertThat(callBuffer.isProcessed()).isFalse(); + } + + @Test + public void runAndFlush_executesBufferedCallsInOrder() { + Runnable runnable1 = mock(Runnable.class); + Runnable runnable2 = mock(Runnable.class); + callBuffer.runOrBuffer(runnable1); + callBuffer.runOrBuffer(runnable2); + + InOrder inOrder = inOrder(runnable1, runnable2); + verify(runnable1, never()).run(); + verify(runnable2, never()).run(); + + callBuffer.runAndFlush(); + + inOrder.verify(runnable1).run(); + inOrder.verify(runnable2).run(); + assertThat(callBuffer.isProcessed()).isTrue(); + } + + @Test + public void runOrBuffer_afterRunAndFlush_runsImmediately() { + callBuffer.runAndFlush(); + assertThat(callBuffer.isProcessed()).isTrue(); + + Runnable runnable = mock(Runnable.class); + callBuffer.runOrBuffer(runnable); + verify(runnable).run(); + } + + @Test + public void abandon_discardsBufferedCalls() { + Runnable runnable = mock(Runnable.class); + callBuffer.runOrBuffer(runnable); + verify(runnable, never()).run(); + + callBuffer.abandon(); + assertThat(callBuffer.isProcessed()).isTrue(); + + // Another flush should not run the abandoned runnable + callBuffer.runAndFlush(); + verify(runnable, never()).run(); + } + + @Test + public void runOrBuffer_afterAbandon_runsImmediately() { + callBuffer.abandon(); + assertThat(callBuffer.isProcessed()).isTrue(); + + Runnable runnable = mock(Runnable.class); + callBuffer.runOrBuffer(runnable); + verify(runnable).run(); + } + + @Test + public void runAndFlush_isIdempotent() { + Runnable runnable = mock(Runnable.class); + callBuffer.runOrBuffer(runnable); + + callBuffer.runAndFlush(); + callBuffer.runAndFlush(); + + verify(runnable, times(1)).run(); + } + + @Test + public void abandon_isIdempotent() { + Runnable runnable = mock(Runnable.class); + callBuffer.runOrBuffer(runnable); + + callBuffer.abandon(); + callBuffer.abandon(); + + verify(runnable, never()).run(); + } + + @Test + public void abandon_afterRunAndFlush_isNoOp() { + Runnable runnable = mock(Runnable.class); + callBuffer.runOrBuffer(runnable); + + callBuffer.runAndFlush(); + callBuffer.abandon(); + + verify(runnable, times(1)).run(); + } + + @Test + public void runAndFlush_afterAbandon_isNoOp() { + Runnable runnable = mock(Runnable.class); + callBuffer.runOrBuffer(runnable); + + callBuffer.abandon(); + callBuffer.runAndFlush(); + + verify(runnable, never()).run(); + } + + // TODO(sauravzg): How to remove dependency on time using explicit synchronization? + @Test + public void concurrentRunOrBuffer_thenRunAndFlush() throws Exception { + int numThreads = 10; + ExecutorService executor = Executors.newFixedThreadPool(numThreads); + List runnables = new ArrayList<>(); + List> futures = new ArrayList<>(); + for (int i = 0; i < numThreads; i++) { + runnables.add(mock(Runnable.class)); + } + CountDownLatch latch = new CountDownLatch(numThreads); + + for (Runnable runnable : runnables) { + futures.add(executor.submit(() -> { + callBuffer.runOrBuffer(runnable); + latch.countDown(); + })); + } + + latch.await(5, TimeUnit.SECONDS); + for (Runnable runnable : runnables) { + verify(runnable, never()).run(); + } + + callBuffer.runAndFlush(); + + for (Runnable runnable : runnables) { + verify(runnable).run(); + } + + for (Future future : futures) { + future.get(); + } + + executor.shutdown(); + executor.awaitTermination(5, TimeUnit.SECONDS); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzClientInterceptorTest.java b/xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzClientInterceptorTest.java new file mode 100644 index 00000000000..cee7681ddb6 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzClientInterceptorTest.java @@ -0,0 +1,175 @@ +/* + * 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.mock; +import static org.mockito.Mockito.when; + +import com.google.protobuf.Any; +import com.google.protobuf.BoolValue; +import io.envoyproxy.envoy.config.core.v3.GrpcService; +import io.envoyproxy.envoy.config.core.v3.RuntimeFeatureFlag; +import io.envoyproxy.envoy.config.core.v3.RuntimeFractionalPercent; +import io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz; +import io.envoyproxy.envoy.extensions.grpc_service.call_credentials.access_token.v3.AccessTokenCredentials; +import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.google_default.v3.GoogleDefaultCredentials; +import io.envoyproxy.envoy.service.auth.v3.AuthorizationGrpc; +import io.envoyproxy.envoy.type.v3.FractionalPercent; +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptor; +import io.grpc.MethodDescriptor; +import io.grpc.internal.FailingClientCall; +import io.grpc.testing.TestMethodDescriptors; +import io.grpc.xds.internal.ThreadSafeRandom; +import io.grpc.xds.internal.headermutations.HeaderMutator; +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; + +@RunWith(JUnit4.class) +public class ExtAuthzClientInterceptorTest { + + @Rule + public final MockitoRule mockito = MockitoJUnit.rule(); + + @Mock + private CheckResponseHandler responseHandler; + @Mock + private HeaderMutator headerMutator; + + @Mock + private CheckRequestBuilder checkRequestBuilder; + + @Mock + ClientCall expectedCall; + + @Mock + ClientCall nextCall; + + private AuthorizationGrpc.AuthorizationStub authzStub; + + @Mock + private ThreadSafeRandom random; + + @Mock + private BufferingAuthzClientCall.Factory clientCallFactory; + + @Mock + private Channel next; + + private MethodDescriptor method = TestMethodDescriptors.voidMethod(); + private CallOptions callOptions = CallOptions.DEFAULT; + + @Before + public void setUp() { + authzStub = AuthorizationGrpc.newStub(mock(Channel.class)); + } + + @Test + public void interceptCall_denyAtDisable() throws ExtAuthzParseException { + when(random.nextInt(100)).thenReturn(50); + ExtAuthz extAuthzProto = ExtAuthz.newBuilder().setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder().setTargetUri("test-cluster") + .addChannelCredentialsPlugin(Any.pack(GoogleDefaultCredentials.newBuilder().build())) + .addCallCredentialsPlugin( + Any.pack(AccessTokenCredentials.newBuilder().setToken("fake-token").build())) + .build()) + .build()) + .setFilterEnabled(RuntimeFractionalPercent.newBuilder() + .setDefaultValue(FractionalPercent.newBuilder().setNumerator(100) + .setDenominator(FractionalPercent.DenominatorType.HUNDRED).build()) + .build()) + .setDenyAtDisable( + RuntimeFeatureFlag.newBuilder().setDefaultValue(BoolValue.of(true)).build()) + .setStatusOnError( + io.envoyproxy.envoy.type.v3.HttpStatus.newBuilder().setCodeValue(403).build()) + .build(); + ExtAuthzConfig config = ExtAuthzConfig.fromProto(extAuthzProto); + ClientInterceptor interceptor = ExtAuthzClientInterceptor.INSTANCE.create(config, authzStub, + random, clientCallFactory, checkRequestBuilder, responseHandler, headerMutator); + + ClientCall call = interceptor.interceptCall(method, callOptions, next); + + assertThat(call).isInstanceOf(FailingClientCall.class); + } + + @Test + public void interceptCall_delegateToRealCall() throws ExtAuthzParseException { + when(random.nextInt(100)).thenReturn(50); + ExtAuthz extAuthzProto = ExtAuthz.newBuilder() + .setGrpcService(io.envoyproxy.envoy.config.core.v3.GrpcService.newBuilder() + .setGoogleGrpc(io.envoyproxy.envoy.config.core.v3.GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("test-cluster") + .addChannelCredentialsPlugin( + Any.pack(GoogleDefaultCredentials.newBuilder().build())) + .addCallCredentialsPlugin( + Any.pack(AccessTokenCredentials.newBuilder().setToken("fake-token").build())) + .build())) + .setFilterEnabled(RuntimeFractionalPercent.newBuilder() + .setDefaultValue(FractionalPercent.newBuilder().setNumerator(100) + .setDenominator(FractionalPercent.DenominatorType.HUNDRED).build()) + .build()) + .build(); + ExtAuthzConfig config = ExtAuthzConfig.fromProto(extAuthzProto); + ClientInterceptor interceptor = ExtAuthzClientInterceptor.INSTANCE.create(config, authzStub, + random, clientCallFactory, checkRequestBuilder, responseHandler, headerMutator); + when(next.newCall(method, callOptions)).thenReturn(expectedCall); + + ClientCall call = interceptor.interceptCall(method, callOptions, next); + + assertThat(call).isSameInstanceAs(expectedCall); + } + + @Test + public void interceptCall_factoryCreatesCall() throws ExtAuthzParseException { + ExtAuthz extAuthzProto = ExtAuthz.newBuilder() + .setGrpcService(io.envoyproxy.envoy.config.core.v3.GrpcService.newBuilder() + .setGoogleGrpc(io.envoyproxy.envoy.config.core.v3.GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("test-cluster") + .addChannelCredentialsPlugin( + Any.pack(GoogleDefaultCredentials.newBuilder().build())) + .addCallCredentialsPlugin( + Any.pack(AccessTokenCredentials.newBuilder().setToken("fake-token").build())) + .build()) + .build()) + .setFilterEnabled(RuntimeFractionalPercent.newBuilder() + .setDefaultValue(FractionalPercent.newBuilder().setNumerator(0) + .setDenominator(FractionalPercent.DenominatorType.HUNDRED).build()) + .build()) + .build(); + ExtAuthzConfig config = ExtAuthzConfig.fromProto(extAuthzProto); + when(random.nextInt(100)).thenReturn(50); + ClientInterceptor interceptor = ExtAuthzClientInterceptor.INSTANCE.create(config, authzStub, + random, clientCallFactory, checkRequestBuilder, responseHandler, headerMutator); + when(next.newCall(method, callOptions)).thenReturn(nextCall); + when(clientCallFactory.create(eq(nextCall), eq(config), eq(authzStub), eq(checkRequestBuilder), + eq(responseHandler), eq(headerMutator), eq(method), any(CallBuffer.class))) + .thenReturn(expectedCall); + ClientCall call = interceptor.interceptCall(method, callOptions, next); + assertThat(call).isSameInstanceAs(expectedCall); + } +} From 2e7cdf11e6051045f9865a14f37f0f6b1ee53e40 Mon Sep 17 00:00:00 2001 From: Saurav Date: Thu, 23 Oct 2025 10:26:01 +0000 Subject: [PATCH 7/8] feat(xds): Add ExtAuthzServerInterceptor and tests This commit introduces the `ExtAuthzServerInterceptor`, a server interceptor that performs external authorization for incoming RPCs. The interceptor checks if the external authorization filter is enabled. If it is, it calls the external authorization service and handles the response. It supports both unary and streaming RPCs. The interceptor handles the following scenarios: - Allow responses: The RPC is allowed to proceed. - Deny responses: The RPC is denied with a `PERMISSION_DENIED` status. - Authorization service errors: The RPC is either denied or allowed to proceed based on the `failure_mode_allow` configuration. This commit also includes comprehensive integration tests for the `ExtAuthzServerInterceptor`, covering various scenarios and configurations. --- .../extauthz/ExtAuthzServerInterceptor.java | 236 +++++++ .../ExtAuthzServerInterceptorTest.java | 583 ++++++++++++++++++ 2 files changed, 819 insertions(+) create mode 100644 xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzServerInterceptor.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzServerInterceptorTest.java diff --git a/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzServerInterceptor.java b/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzServerInterceptor.java new file mode 100644 index 00000000000..4f5a97d367f --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzServerInterceptor.java @@ -0,0 +1,236 @@ +/* + * 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.protobuf.util.Timestamps; +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.ForwardingServerCall.SimpleForwardingServerCall; +import io.grpc.ForwardingServerCallListener; +import io.grpc.Metadata; +import io.grpc.ServerCall; +import io.grpc.ServerCall.Listener; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.stub.StreamObserver; +import io.grpc.xds.internal.Matchers.FractionMatcher; +import io.grpc.xds.internal.ThreadSafeRandom; +import io.grpc.xds.internal.headermutations.HeaderMutations.ResponseHeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderMutator; +import java.util.concurrent.atomic.AtomicReference; + +/** + * A server interceptor that performs external authorization for incoming RPCs. + */ +public final class ExtAuthzServerInterceptor implements ServerInterceptor { + + /** + * A factory for creating {@link ExtAuthzServerInterceptor} instances. + */ + @FunctionalInterface + public interface Factory { + /** + * Creates a new {@link ExtAuthzServerInterceptor}. + * + * @param config the external authorization configuration. + * @param authzStub the gRPC stub for the authorization service. + * @param random the random number generator for filter matching. + * @param checkRequestBuilder the builder for creating authorization check requests. + * @param responseHandler the handler for processing authorization responses. + * @param headerMutator the mutator for applying header mutations. + * @return a new {@link ServerInterceptor}. + */ + ServerInterceptor create(ExtAuthzConfig config, AuthorizationGrpc.AuthorizationStub authzStub, + ThreadSafeRandom random, CheckRequestBuilder checkRequestBuilder, + CheckResponseHandler responseHandler, HeaderMutator headerMutator); + } + + /** + * A factory for creating {@link ExtAuthzServerInterceptor} instances. This is the only supported + * way to create a new ExtAuthzServerInterceptor. + */ + public static final Factory INSTANCE = ExtAuthzServerInterceptor::new; + + private final ExtAuthzConfig config; + private final AuthorizationGrpc.AuthorizationStub authzStub; + private final ThreadSafeRandom random; + private final CheckRequestBuilder checkRequestBuilder; + private final CheckResponseHandler responseHandler; + private final HeaderMutator headerMutator; + + private ExtAuthzServerInterceptor(ExtAuthzConfig config, + AuthorizationGrpc.AuthorizationStub authzStub, ThreadSafeRandom random, + CheckRequestBuilder checkRequestBuilder, + CheckResponseHandler responseHandler, HeaderMutator headerMutator) { + this.config = config; + this.random = random; + this.authzStub = authzStub; + this.checkRequestBuilder = checkRequestBuilder; + this.responseHandler = responseHandler; + this.headerMutator = headerMutator; + } + + /** + * Intercepts an incoming call to perform external authorization. + * + * @param call the server call to intercept. + * @param headers the headers of the incoming call. + * @param next the next handler in the chain. + * @return a listener for the server call. + */ + @Override + public ServerCall.Listener interceptCall(ServerCall call, + final Metadata headers, ServerCallHandler next) { + FractionMatcher filterEnabled = config.filterEnabled(); + if (random.nextInt(filterEnabled.denominator()) < filterEnabled.numerator()) { + if (config.denyAtDisable()) { + call.close(config.statusOnError(), new Metadata()); + return new ServerCall.Listener() {}; + } + return next.startCall(call, headers); + } + ExtAuthzForwardingListener listener = new ExtAuthzForwardingListener<>(config, + authzStub, headers, call, next, checkRequestBuilder, responseHandler, headerMutator); + listener.startAuthzCall(); + return listener; + } + + /** + * A forwarding server call listener that handles the external authorization process. + */ + private static final class ExtAuthzForwardingListener + extends ForwardingServerCallListener { + private static final String X_ENVOY_AUTH_FAILURE_MODE_ALLOWED = + "x-envoy-auth-failure-mode-allowed"; + + private final ExtAuthzConfig config; + private final AuthorizationGrpc.AuthorizationStub authzStub; + private final Metadata headers; + private final ServerCall realServerCall; + private final ServerCallHandler serverCallHandler; + private final CheckRequestBuilder checkRequestBuilder; + private final CheckResponseHandler responseHandler; + private final HeaderMutator headerMutator; + private final AtomicReference> delegateListener; + + /** + * Constructs a new {@link ExtAuthzForwardingListener}. + */ + ExtAuthzForwardingListener(ExtAuthzConfig config, + AuthorizationGrpc.AuthorizationStub authzStub, Metadata headers, + ServerCall serverCall, ServerCallHandler serverCallHandler, + CheckRequestBuilder checkRequestBuilder, CheckResponseHandler responseHandler, + HeaderMutator headerMutator) { + this.config = config; + this.authzStub = authzStub; + this.headers = headers; + this.realServerCall = serverCall; + this.serverCallHandler = serverCallHandler; + this.checkRequestBuilder = checkRequestBuilder; + this.responseHandler = responseHandler; + this.headerMutator = headerMutator; + this.delegateListener = + new AtomicReference>(new ServerCall.Listener() {}); + } + + /** + * Starts the external authorization call. + */ + void startAuthzCall() { + CheckRequest checkRequest = checkRequestBuilder.buildRequest(realServerCall, headers, + Timestamps.fromMillis(System.currentTimeMillis())); + StreamObserver observer = new StreamObserver() { + @Override + public void onNext(CheckResponse value) { + // The handleResponse method may add or modify headers based on the authorization + // response. + AuthzResponse authzResponse = responseHandler.handleResponse(value, headers); + if (authzResponse.decision() == AuthzResponse.Decision.ALLOW) { + AuthzServerCall authzServerCall = new AuthzServerCall<>(realServerCall, + authzResponse.responseHeaderMutations(), headerMutator); + delegateListener + .set(serverCallHandler.startCall(authzServerCall, authzResponse.headers().get())); + } else { + // A deny response is guaranteed to have a status set, so the `get` without + // check is safe. + realServerCall.close(authzResponse.status().get(), new Metadata()); + } + } + + @Override + public void onError(Throwable t) { + if (config.failureModeAllow()) { + if (config.failureModeAllowHeaderAdd()) { + Metadata.Key key = Metadata.Key.of(X_ENVOY_AUTH_FAILURE_MODE_ALLOWED, + Metadata.ASCII_STRING_MARSHALLER); + headers.put(key, "true"); + } + delegateListener.set(serverCallHandler.startCall(realServerCall, headers)); + } else { + realServerCall.close(config.statusOnError().withCause(t), new Metadata()); + } + } + + @Override + public void onCompleted() { + // No-op. The authorization service uses a unary RPC, so we only expect one response. + } + }; + authzStub.check(checkRequest, observer); + } + + @Override + protected Listener delegate() { + return delegateListener.get(); + } + } + + /** + * A server call that applies response header mutations from the authorization service. + */ + private static class AuthzServerCall + extends SimpleForwardingServerCall { + private final ResponseHeaderMutations responseHeaderMutations; + private final HeaderMutator headerMutator; + + /** + * Constructs a new {@link AuthzServerCall}. + * + * @param delegate the original server call. + * @param responseHeaderMutations the response header mutations to apply. + * @param headerMutator the mutator for applying header mutations. + */ + private AuthzServerCall(ServerCall delegate, + ResponseHeaderMutations responseHeaderMutations, HeaderMutator headerMutator) { + super(delegate); + this.responseHeaderMutations = responseHeaderMutations; + this.headerMutator = headerMutator; + } + + /** + * Sends the headers after applying any mutations from the authorization service. + * + * @param headers the headers to send. + */ + @Override + public void sendHeaders(Metadata headers) { + headerMutator.applyResponseMutations(responseHeaderMutations, headers); + super.sendHeaders(headers); + } + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzServerInterceptorTest.java b/xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzServerInterceptorTest.java new file mode 100644 index 00000000000..a86bcbdc52b --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/extauthz/ExtAuthzServerInterceptorTest.java @@ -0,0 +1,583 @@ +/* + * 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.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableList; +import com.google.protobuf.Any; +import com.google.protobuf.BoolValue; +import com.google.protobuf.Timestamp; +import io.envoyproxy.envoy.config.core.v3.GrpcService; +import io.envoyproxy.envoy.config.core.v3.RuntimeFeatureFlag; +import io.envoyproxy.envoy.config.core.v3.RuntimeFractionalPercent; +import io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz; +import io.envoyproxy.envoy.extensions.grpc_service.call_credentials.access_token.v3.AccessTokenCredentials; +import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.google_default.v3.GoogleDefaultCredentials; +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.type.v3.FractionalPercent; +import io.envoyproxy.envoy.type.v3.FractionalPercent.DenominatorType; +import io.envoyproxy.envoy.type.v3.HttpStatus; +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.ServerInterceptors; +import io.grpc.Status; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; +import io.grpc.stub.ClientCalls; +import io.grpc.stub.MetadataUtils; +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.internal.ThreadSafeRandom; +import io.grpc.xds.internal.headermutations.HeaderMutations.ResponseHeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderMutator; +import java.io.IOException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +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; + +/** Integration tests for {@link ExtAuthzServerInterceptor}. */ +@RunWith(JUnit4.class) +public class ExtAuthzServerInterceptorTest { + + @Rule + public final MockitoRule mockito = MockitoJUnit.rule(); + + @Mock + private ThreadSafeRandom mockRandom; + @Mock + private CheckRequestBuilder mockCheckRequestBuilder; + @Mock + private CheckResponseHandler mockResponseHandler; + @Mock + private HeaderMutator mockHeaderMutator; + @Mock + private AuthorizationGrpc.AuthorizationImplBase authzService; + + private Server server; + private ManagedChannel channel; + private final AtomicReference serverHeadersCapture = new AtomicReference<>(); + private final AtomicReference clientResponseHeadersCapture = new AtomicReference<>(); + private final AtomicReference clientResponseTrailersCapture = new AtomicReference<>(); + + private final SimpleServiceGrpc.SimpleServiceImplBase simpleServiceImpl = + new SimpleServiceGrpc.SimpleServiceImplBase() { + @Override + public void unaryRpc(SimpleRequest request, + StreamObserver responseObserver) { + responseObserver.onNext(SimpleResponse.newBuilder() + .setResponseMessage("Hello " + request.getRequestMessage()).build()); + responseObserver.onCompleted(); + } + + @Override + public StreamObserver bidiStreamingRpc( + final StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(SimpleRequest request) { + responseObserver.onNext(SimpleResponse.newBuilder() + .setResponseMessage("Hello " + request.getRequestMessage()).build()); + } + + @Override + public void onError(Throwable t) { + responseObserver.onError(t); + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + + @Before + public void setUp() throws IOException {} + + @After + public void tearDown() { + if (server != null) { + server.shutdownNow(); + } + if (channel != null) { + channel.shutdownNow(); + } + } + + @Test + public void interceptCall_allow() throws Exception { + ExtAuthzConfig config = buildExtAuthzConfig(false, false, 403, false, 0); + String serverName = InProcessServerBuilder.generateName(); + channel = buildChannel(serverName); + AuthorizationGrpc.AuthorizationStub authzStub = AuthorizationGrpc.newStub(channel); + server = buildAndStartServer(config, authzStub, serverName); + CheckRequest checkRequest = CheckRequest.getDefaultInstance(); + CheckResponse checkResponse = CheckResponse.newBuilder() + .setStatus(com.google.rpc.Status.newBuilder().setCode(Status.Code.OK.value())).build(); + ResponseHeaderMutations responseHeaderMutations = + ResponseHeaderMutations.create(ImmutableList.of()); + setUpAllowCheck(checkRequest, checkResponse, responseHeaderMutations); + + SimpleServiceUnaryResponseObserver responseObserver = new SimpleServiceUnaryResponseObserver(); + ClientCalls.asyncUnaryCall( + channel.newCall(SimpleServiceGrpc.getUnaryRpcMethod(), io.grpc.CallOptions.DEFAULT), + SimpleRequest.newBuilder().setRequestMessage("world").build(), responseObserver); + responseObserver.await(); + assertThat(responseObserver.getResponse().getResponseMessage()).isEqualTo("Hello world"); + assertThat(responseObserver.getError()).isNull(); + assertThat(serverHeadersCapture.get() + .get(Metadata.Key.of("auth-key", Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("auth-value"); + assertThat(clientResponseHeadersCapture.get() + .get(Metadata.Key.of("client-resp-key", Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("client-resp-value"); + assertThat(clientResponseTrailersCapture.get()).isNotNull(); + } + + @Test + public void interceptCall_deny() throws Exception { + ExtAuthzConfig config = buildExtAuthzConfig(false, false, 403, false, 0); + String serverName = InProcessServerBuilder.generateName(); + channel = buildChannel(serverName); + AuthorizationGrpc.AuthorizationStub authzStub = AuthorizationGrpc.newStub(channel); + server = buildAndStartServer(config, authzStub, serverName); + when(mockRandom.nextInt(100)).thenReturn(50); + CheckRequest checkRequest = CheckRequest.getDefaultInstance(); + when(mockCheckRequestBuilder.buildRequest(any(ServerCall.class), any(Metadata.class), + any(Timestamp.class))).thenReturn(checkRequest); + CheckResponse checkResponse = CheckResponse.newBuilder() + .setStatus( + com.google.rpc.Status.newBuilder().setCode(Status.Code.PERMISSION_DENIED.value())) + .build(); + doAnswer(invocation -> { + StreamObserver observer = invocation.getArgument(1); + observer.onNext(checkResponse); + observer.onCompleted(); + return null; + }).when(authzService).check(eq(checkRequest), ArgumentMatchers.any()); + Status expectedStatus = Status.PERMISSION_DENIED.withDescription("ext authz denied"); + AuthzResponse denyResponse = AuthzResponse.deny(expectedStatus).build(); + when(mockResponseHandler.handleResponse(eq(checkResponse), any())).thenReturn(denyResponse); + + SimpleServiceUnaryResponseObserver responseObserver = new SimpleServiceUnaryResponseObserver(); + ClientCalls.asyncUnaryCall( + channel.newCall(SimpleServiceGrpc.getUnaryRpcMethod(), io.grpc.CallOptions.DEFAULT), + SimpleRequest.newBuilder().setRequestMessage("world").build(), responseObserver); + responseObserver.await(); + + assertThat(responseObserver.getResponse()).isNull(); + assertThat(responseObserver.getError()).isNotNull(); + assertThat(responseObserver.getError().getCode()).isEqualTo(expectedStatus.getCode()); + assertThat(responseObserver.getError().getDescription()) + .isEqualTo(expectedStatus.getDescription()); + } + + @Test + public void interceptCall_authzServerError_failCall() throws Exception { + ExtAuthzConfig config = buildExtAuthzConfig(false, false, 503, false, 0); + String serverName = InProcessServerBuilder.generateName(); + channel = buildChannel(serverName); + AuthorizationGrpc.AuthorizationStub authzStub = AuthorizationGrpc.newStub(channel); + server = buildAndStartServer(config, authzStub, serverName); + when(mockRandom.nextInt(100)).thenReturn(50); + CheckRequest checkRequest = CheckRequest.getDefaultInstance(); + when(mockCheckRequestBuilder.buildRequest(any(ServerCall.class), any(Metadata.class), + any(Timestamp.class))).thenReturn(checkRequest); + Status authzError = Status.UNAVAILABLE.withDescription("authz server unavailable"); + doAnswer(invocation -> { + StreamObserver observer = invocation.getArgument(1); + observer.onError(authzError.asRuntimeException()); + return null; + }).when(authzService).check(eq(checkRequest), ArgumentMatchers.any()); + + SimpleServiceUnaryResponseObserver responseObserver = new SimpleServiceUnaryResponseObserver(); + ClientCalls.asyncUnaryCall( + channel.newCall(SimpleServiceGrpc.getUnaryRpcMethod(), io.grpc.CallOptions.DEFAULT), + SimpleRequest.newBuilder().setRequestMessage("world").build(), responseObserver); + responseObserver.await(); + + assertThat(responseObserver.getResponse()).isNull(); + assertThat(responseObserver.getError()).isNotNull(); + assertThat(responseObserver.getError().getCode()).isEqualTo(config.statusOnError().getCode()); + } + + @Test + public void interceptCall_authzServerError_allow() throws Exception { + ExtAuthzConfig config = buildExtAuthzConfig(true, false, 503, false, 0); + String serverName = InProcessServerBuilder.generateName(); + channel = buildChannel(serverName); + AuthorizationGrpc.AuthorizationStub authzStub = AuthorizationGrpc.newStub(channel); + server = buildAndStartServer(config, authzStub, serverName); + when(mockRandom.nextInt(100)).thenReturn(50); + CheckRequest checkRequest = CheckRequest.getDefaultInstance(); + when(mockCheckRequestBuilder.buildRequest(any(ServerCall.class), any(Metadata.class), + any(Timestamp.class))).thenReturn(checkRequest); + Status authzError = Status.UNAVAILABLE.withDescription("authz server unavailable"); + doAnswer(invocation -> { + StreamObserver observer = invocation.getArgument(1); + observer.onError(authzError.asRuntimeException()); + return null; + }).when(authzService).check(eq(checkRequest), ArgumentMatchers.any()); + + SimpleServiceUnaryResponseObserver responseObserver = new SimpleServiceUnaryResponseObserver(); + ClientCalls.asyncUnaryCall( + channel.newCall(SimpleServiceGrpc.getUnaryRpcMethod(), io.grpc.CallOptions.DEFAULT), + SimpleRequest.newBuilder().setRequestMessage("world").build(), responseObserver); + responseObserver.await(); + + assertThat(responseObserver.getResponse().getResponseMessage()).isEqualTo("Hello world"); + assertThat(responseObserver.getError()).isNull(); + assertThat(serverHeadersCapture.get().get( + Metadata.Key.of("x-envoy-auth-failure-mode-allowed", Metadata.ASCII_STRING_MARSHALLER))) + .isNull(); + } + + @Test + public void interceptCall_authzServerError_allowWithHeaderAdd() throws Exception { + ExtAuthzConfig config = buildExtAuthzConfig(true, true, 503, false, 0); + String serverName = InProcessServerBuilder.generateName(); + channel = buildChannel(serverName); + AuthorizationGrpc.AuthorizationStub authzStub = AuthorizationGrpc.newStub(channel); + server = buildAndStartServer(config, authzStub, serverName); + when(mockRandom.nextInt(100)).thenReturn(50); + CheckRequest checkRequest = CheckRequest.getDefaultInstance(); + when(mockCheckRequestBuilder.buildRequest(any(ServerCall.class), any(Metadata.class), + any(Timestamp.class))).thenReturn(checkRequest); + Status authzError = Status.UNAVAILABLE.withDescription("authz server unavailable"); + doAnswer(invocation -> { + StreamObserver observer = invocation.getArgument(1); + observer.onError(authzError.asRuntimeException()); + return null; + }).when(authzService).check(eq(checkRequest), ArgumentMatchers.any()); + + SimpleServiceUnaryResponseObserver responseObserver = new SimpleServiceUnaryResponseObserver(); + ClientCalls.asyncUnaryCall( + channel.newCall(SimpleServiceGrpc.getUnaryRpcMethod(), io.grpc.CallOptions.DEFAULT), + SimpleRequest.newBuilder().setRequestMessage("world").build(), responseObserver); + responseObserver.await(); + + assertThat(responseObserver.getResponse().getResponseMessage()).isEqualTo("Hello world"); + assertThat(responseObserver.getError()).isNull(); + assertThat(serverHeadersCapture.get().get( + Metadata.Key.of("x-envoy-auth-failure-mode-allowed", Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("true"); + } + + @Test + public void interceptCall_filterDisabled_denyAtDisable() throws Exception { + ExtAuthzConfig config = buildExtAuthzConfig(false, false, 403, true, 100); + String serverName = InProcessServerBuilder.generateName(); + channel = buildChannel(serverName); + AuthorizationGrpc.AuthorizationStub authzStub = AuthorizationGrpc.newStub(channel); + server = buildAndStartServer(config, authzStub, serverName); + when(mockRandom.nextInt(100)).thenReturn(50); + + SimpleServiceUnaryResponseObserver responseObserver = new SimpleServiceUnaryResponseObserver(); + ClientCalls.asyncUnaryCall( + channel.newCall(SimpleServiceGrpc.getUnaryRpcMethod(), io.grpc.CallOptions.DEFAULT), + SimpleRequest.newBuilder().setRequestMessage("world").build(), responseObserver); + responseObserver.await(); + + assertThat(responseObserver.getResponse()).isNull(); + assertThat(responseObserver.getError()).isNotNull(); + assertThat(responseObserver.getError().getCode()).isEqualTo(config.statusOnError().getCode()); + verify(authzService, never()).check(any(), any()); + } + + @Test + public void interceptCall_filterDisabled_allow() throws Exception { + ExtAuthzConfig config = buildExtAuthzConfig(false, false, 403, false, 100); + String serverName = InProcessServerBuilder.generateName(); + channel = buildChannel(serverName); + AuthorizationGrpc.AuthorizationStub authzStub = AuthorizationGrpc.newStub(channel); + server = buildAndStartServer(config, authzStub, serverName); + when(mockRandom.nextInt(100)).thenReturn(50); + + SimpleServiceUnaryResponseObserver responseObserver = new SimpleServiceUnaryResponseObserver(); + ClientCalls.asyncUnaryCall( + channel.newCall(SimpleServiceGrpc.getUnaryRpcMethod(), io.grpc.CallOptions.DEFAULT), + SimpleRequest.newBuilder().setRequestMessage("world").build(), responseObserver); + responseObserver.await(); + + assertThat(responseObserver.getResponse().getResponseMessage()).isEqualTo("Hello world"); + assertThat(responseObserver.getError()).isNull(); + verify(authzService, never()).check(any(), any()); + } + + @Test + public void interceptCall_streaming_allow() throws Exception { + ExtAuthzConfig config = buildExtAuthzConfig(false, false, 403, false, 0); + String serverName = InProcessServerBuilder.generateName(); + channel = buildChannel(serverName); + AuthorizationGrpc.AuthorizationStub authzStub = AuthorizationGrpc.newStub(channel); + server = buildAndStartServer(config, authzStub, serverName); + when(mockRandom.nextInt(100)).thenReturn(50); + CheckRequest checkRequest = CheckRequest.getDefaultInstance(); + when(mockCheckRequestBuilder.buildRequest(any(ServerCall.class), any(Metadata.class), + any(Timestamp.class))).thenReturn(checkRequest); + CheckResponse checkResponse = CheckResponse.newBuilder() + .setStatus(com.google.rpc.Status.newBuilder().setCode(Status.Code.OK.value())).build(); + doAnswer(invocation -> { + StreamObserver observer = invocation.getArgument(1); + observer.onNext(checkResponse); + observer.onCompleted(); + return null; + }).when(authzService).check(eq(checkRequest), ArgumentMatchers.any()); + AuthzResponse allowResponse = AuthzResponse.allow(new Metadata()).build(); + when(mockResponseHandler.handleResponse(eq(checkResponse), any())).thenReturn(allowResponse); + + SimpleServiceStreamingResponseObserver responseObserver = + new SimpleServiceStreamingResponseObserver(); + StreamObserver requestObserver = ClientCalls.asyncBidiStreamingCall( + channel.newCall(SimpleServiceGrpc.getBidiStreamingRpcMethod(), io.grpc.CallOptions.DEFAULT), + responseObserver); + requestObserver.onNext(SimpleRequest.newBuilder().setRequestMessage("world").build()); + requestObserver.onCompleted(); + responseObserver.await(); + + assertThat(responseObserver.getResponses()).hasSize(1); + assertThat(responseObserver.getResponses().get(0).getResponseMessage()) + .isEqualTo("Hello world"); + assertThat(responseObserver.getError()).isNull(); + } + + @Test + public void interceptCall_streaming_deny() throws Exception { + ExtAuthzConfig config = buildExtAuthzConfig(false, false, 403, false, 0); + String serverName = InProcessServerBuilder.generateName(); + channel = buildChannel(serverName); + AuthorizationGrpc.AuthorizationStub authzStub = AuthorizationGrpc.newStub(channel); + server = buildAndStartServer(config, authzStub, serverName); + when(mockRandom.nextInt(100)).thenReturn(50); + CheckRequest checkRequest = CheckRequest.getDefaultInstance(); + when(mockCheckRequestBuilder.buildRequest(any(ServerCall.class), any(Metadata.class), + any(Timestamp.class))).thenReturn(checkRequest); + CheckResponse checkResponse = CheckResponse.newBuilder() + .setStatus( + com.google.rpc.Status.newBuilder().setCode(Status.Code.PERMISSION_DENIED.value())) + .build(); + doAnswer(invocation -> { + StreamObserver observer = invocation.getArgument(1); + observer.onNext(checkResponse); + observer.onCompleted(); + return null; + }).when(authzService).check(eq(checkRequest), ArgumentMatchers.any()); + Status expectedStatus = Status.PERMISSION_DENIED.withDescription("ext authz denied"); + AuthzResponse denyResponse = AuthzResponse.deny(expectedStatus).build(); + when(mockResponseHandler.handleResponse(eq(checkResponse), any())).thenReturn(denyResponse); + + SimpleServiceStreamingResponseObserver responseObserver = + new SimpleServiceStreamingResponseObserver(); + StreamObserver requestObserver = ClientCalls.asyncBidiStreamingCall( + channel.newCall(SimpleServiceGrpc.getBidiStreamingRpcMethod(), io.grpc.CallOptions.DEFAULT), + responseObserver); + requestObserver.onNext(SimpleRequest.newBuilder().setRequestMessage("world").build()); + requestObserver.onCompleted(); + responseObserver.await(); + + assertThat(responseObserver.getResponses()).isEmpty(); + assertThat(responseObserver.getError()).isNotNull(); + assertThat(responseObserver.getError().getCode()).isEqualTo(expectedStatus.getCode()); + assertThat(responseObserver.getError().getDescription()) + .isEqualTo(expectedStatus.getDescription()); + } + + private ManagedChannel buildChannel(String serverName) { + return InProcessChannelBuilder.forName(serverName).intercept(MetadataUtils + .newCaptureMetadataInterceptor(clientResponseHeadersCapture, clientResponseTrailersCapture)) + .directExecutor().build(); + + } + + private Server buildAndStartServer(ExtAuthzConfig config, + AuthorizationGrpc.AuthorizationStub authzStub, String serverName) throws IOException { + ServerInterceptor interceptor = ExtAuthzServerInterceptor.INSTANCE.create(config, authzStub, + mockRandom, mockCheckRequestBuilder, mockResponseHandler, mockHeaderMutator); + + return InProcessServerBuilder.forName(serverName).addService(authzService) + .addService(ServerInterceptors.intercept(simpleServiceImpl, + new MetadataCapturingServerInterceptor(serverHeadersCapture), interceptor)) + .directExecutor().build().start(); + + } + + private ExtAuthzConfig buildExtAuthzConfig(boolean failureModeAllow, + boolean failureModeAllowHeaderAdd, int httpStatusOnError, boolean denyAtDisable, int percent) + throws ExtAuthzParseException { + Any googleDefaultChannelCreds = Any.pack(GoogleDefaultCredentials.newBuilder().build()); + Any fakeAccessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("fake-token").build()); + ExtAuthz extAuthz = ExtAuthz.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder().setTargetUri("test-cluster") + .addChannelCredentialsPlugin(googleDefaultChannelCreds) + .addCallCredentialsPlugin(fakeAccessTokenCreds).build()) + .build()) + .setFailureModeAllow(failureModeAllow) + .setFailureModeAllowHeaderAdd(failureModeAllowHeaderAdd) + .setDenyAtDisable( + RuntimeFeatureFlag.newBuilder().setDefaultValue(BoolValue.of(denyAtDisable)).build()) + .setStatusOnError(HttpStatus.newBuilder().setCodeValue(httpStatusOnError).build()) + .setFilterEnabled(RuntimeFractionalPercent.newBuilder() + .setDefaultValue(FractionalPercent.newBuilder().setNumerator(percent) + .setDenominator(DenominatorType.HUNDRED).build()) + .build()) + .setIncludePeerCertificate(denyAtDisable).build(); + return ExtAuthzConfig.fromProto(extAuthz); + } + + private static class SimpleServiceUnaryResponseObserver + implements StreamObserver { + + final AtomicReference responseCapture = new AtomicReference<>(); + final AtomicReference errorCapture = new AtomicReference<>(); + final CountDownLatch latch = new CountDownLatch(1); + + @Override + public void onNext(SimpleResponse value) { + responseCapture.set(value); + } + + @Override + public void onError(Throwable t) { + errorCapture.set(Status.fromThrowable(t)); + latch.countDown(); + } + + @Override + public void onCompleted() { + latch.countDown(); + } + + public void await() throws InterruptedException { + latch.await(5, TimeUnit.SECONDS); + } + + public SimpleResponse getResponse() { + return responseCapture.get(); + } + + public Status getError() { + return errorCapture.get(); + } + } + + private static class SimpleServiceStreamingResponseObserver + implements StreamObserver { + + final ImmutableList.Builder responsesCapture = new ImmutableList.Builder<>(); + final AtomicReference errorCapture = new AtomicReference<>(); + final CountDownLatch latch = new CountDownLatch(1); + + @Override + public void onNext(SimpleResponse value) { + responsesCapture.add(value); + } + + @Override + public void onError(Throwable t) { + errorCapture.set(Status.fromThrowable(t)); + latch.countDown(); + } + + @Override + public void onCompleted() { + latch.countDown(); + } + + public void await() throws InterruptedException { + latch.await(5, TimeUnit.SECONDS); + } + + public ImmutableList getResponses() { + return responsesCapture.build(); + } + + public Status getError() { + return errorCapture.get(); + } + } + + private static final class MetadataCapturingServerInterceptor implements ServerInterceptor { + private final AtomicReference headersCapture; + + MetadataCapturingServerInterceptor(AtomicReference headersCapture) { + this.headersCapture = headersCapture; + } + + @Override + public ServerCall.Listener interceptCall(ServerCall call, + Metadata headers, ServerCallHandler next) { + Metadata metadataCopy = new Metadata(); + metadataCopy.merge(headers); + headersCapture.set(metadataCopy); + return next.startCall(call, headers); + } + } + + private void setUpAllowCheck(CheckRequest checkRequest, CheckResponse checkResponse, + ResponseHeaderMutations responseHeaderMutations) { + when(mockRandom.nextInt(100)).thenReturn(50); + when(mockCheckRequestBuilder.buildRequest(any(ServerCall.class), any(Metadata.class), + any(Timestamp.class))).thenReturn(checkRequest); + doAnswer(invocation -> { + StreamObserver observer = invocation.getArgument(1); + observer.onNext(checkResponse); + observer.onCompleted(); + return null; + }).when(authzService).check(eq(checkRequest), ArgumentMatchers.any()); + Metadata headersFromServer = new Metadata(); + headersFromServer.put(Metadata.Key.of("auth-key", Metadata.ASCII_STRING_MARSHALLER), + "auth-value"); + AuthzResponse allowResponse = AuthzResponse.allow(headersFromServer) + .setResponseHeaderMutations(responseHeaderMutations).build(); + when(mockResponseHandler.handleResponse(eq(checkResponse), any())).thenReturn(allowResponse); + doAnswer(invocation -> { + Metadata headers = invocation.getArgument(1); + headers.put(Metadata.Key.of("client-resp-key", Metadata.ASCII_STRING_MARSHALLER), + "client-resp-value"); + return null; + }).when(mockHeaderMutator).applyResponseMutations(eq(responseHeaderMutations), + any(Metadata.class)); + } +} From 88f9ae30fb40802f388b89ca53176bbd5fc5cebd Mon Sep 17 00:00:00 2001 From: Saurav Date: Thu, 6 Nov 2025 10:14:30 +0000 Subject: [PATCH 8/8] feat(xds): Add ExternalAuthorizationFilter This commit introduces the `ExternalAuthorizationFilter`, an implementation of the `Filter` interface that provides external authorization capabilities. The `ExternalAuthorizationFilter` is responsible for: - Parsing `ExtAuthz` and `ExtAuthzPerRoute` configurations. - Creating `ExtAuthzClientInterceptor` and `ExtAuthzServerInterceptor` to handle client and server-side authorization. - Managing the lifecycle of the authorization stub using a `StubManager`. The `StubManager` is a new class that manages the lifecycle of the `AuthorizationStub`, including creating and caching the gRPC channel and stub based on the provided configuration. This ensures that a single channel and stub are reused for the same configuration, improving performance and resource utilization. --- .../main/java/io/grpc/xds/ExtAuthzFilter.java | 217 +++++++++++++ .../xds/internal/extauthz/StubManager.java | 114 +++++++ .../java/io/grpc/xds/ExtAuthzFilterTest.java | 298 ++++++++++++++++++ .../internal/extauthz/StubManagerTest.java | 152 +++++++++ 4 files changed, 781 insertions(+) create mode 100644 xds/src/main/java/io/grpc/xds/ExtAuthzFilter.java create mode 100644 xds/src/main/java/io/grpc/xds/internal/extauthz/StubManager.java create mode 100644 xds/src/test/java/io/grpc/xds/ExtAuthzFilterTest.java create mode 100644 xds/src/test/java/io/grpc/xds/internal/extauthz/StubManagerTest.java diff --git a/xds/src/main/java/io/grpc/xds/ExtAuthzFilter.java b/xds/src/main/java/io/grpc/xds/ExtAuthzFilter.java new file mode 100644 index 00000000000..c74b8817d90 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/ExtAuthzFilter.java @@ -0,0 +1,217 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds; + +import com.google.protobuf.Any; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Message; +import io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz; +import io.envoyproxy.envoy.service.auth.v3.AuthorizationGrpc; +import io.grpc.ClientInterceptor; +import io.grpc.ServerInterceptor; +import io.grpc.xds.internal.ThreadSafeRandom; +import io.grpc.xds.internal.ThreadSafeRandom.ThreadSafeRandomImpl; +import io.grpc.xds.internal.extauthz.BufferingAuthzClientCall; +import io.grpc.xds.internal.extauthz.CheckRequestBuilder; +import io.grpc.xds.internal.extauthz.CheckResponseHandler; +import io.grpc.xds.internal.extauthz.ExtAuthzCertificateProvider; +import io.grpc.xds.internal.extauthz.ExtAuthzClientInterceptor; +import io.grpc.xds.internal.extauthz.ExtAuthzConfig; +import io.grpc.xds.internal.extauthz.ExtAuthzParseException; +import io.grpc.xds.internal.extauthz.ExtAuthzServerInterceptor; +import io.grpc.xds.internal.extauthz.StubManager; +import io.grpc.xds.internal.grpcservice.InsecureGrpcChannelFactory; +import io.grpc.xds.internal.headermutations.HeaderMutationFilter; +import io.grpc.xds.internal.headermutations.HeaderMutator; +import java.util.concurrent.ScheduledExecutorService; +import javax.annotation.Nullable; + +final class ExtAuthzFilter implements Filter { + + private static final String TYPE_URL = + "type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz"; + + private static final String TYPE_URL_OVERRIDE_CONFIG = + "type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute"; + + + static final class ExtAuthzFilterConfig implements Filter.FilterConfig { + + private final ExtAuthzConfig extAuthzConfig; + + ExtAuthzFilterConfig(ExtAuthzConfig extAuthzConfig) { + this.extAuthzConfig = extAuthzConfig; + } + + public ExtAuthzConfig extAuthzConfig() { + return extAuthzConfig; + } + + @Override + public String typeUrl() { + return ExtAuthzFilter.TYPE_URL; + } + + public static ExtAuthzFilterConfig fromProto(ExtAuthz extAuthzProto) + throws ExtAuthzParseException { + return new ExtAuthzFilterConfig(ExtAuthzConfig.fromProto(extAuthzProto)); + } + } + + // Placeholder for the external authorization filter's override config. + static final class ExtAuthzFilterConfigOverride implements Filter.FilterConfig { + @Override + public final String typeUrl() { + return ExtAuthzFilter.TYPE_URL_OVERRIDE_CONFIG; + } + } + + static final class Provider implements Filter.Provider { + + @Override + public String[] typeUrls() { + return new String[] {TYPE_URL, TYPE_URL_OVERRIDE_CONFIG}; + } + + @Override + public boolean isClientFilter() { + return true; + } + + @Override + public boolean isServerFilter() { + return true; + } + + @Override + public ExtAuthzFilter newInstance(String name) { + // Create a dedicated scheduler for this filter instance's StubManager + StubManager stubManager = StubManager.create(InsecureGrpcChannelFactory.getInstance()); + return new ExtAuthzFilter(stubManager, ThreadSafeRandomImpl.INSTANCE, + BufferingAuthzClientCall.FACTORY_INSTANCE, ExtAuthzCertificateProvider.create(), + CheckRequestBuilder.INSTANCE, CheckResponseHandler.INSTANCE, + ExtAuthzClientInterceptor.INSTANCE, ExtAuthzServerInterceptor.INSTANCE, + HeaderMutationFilter.INSTANCE, HeaderMutator.create()); + } + + @Override + public ConfigOrError parseFilterConfig(Message rawProtoMessage) { + ExtAuthz extAuthzProto; + if (!(rawProtoMessage instanceof Any)) { + return ConfigOrError.fromError("Invalid config type: " + rawProtoMessage.getClass()); + } + Any anyMessage = (Any) rawProtoMessage; + try { + extAuthzProto = anyMessage.unpack(ExtAuthz.class); + return ConfigOrError.fromConfig(ExtAuthzFilterConfig.fromProto(extAuthzProto)); + } catch (InvalidProtocolBufferException | ExtAuthzParseException e) { + return ConfigOrError.fromError("Invalid proto: " + e); + } + } + + @Override + public ConfigOrError parseFilterConfigOverride( + Message rawProtoMessage) { + if (!(rawProtoMessage instanceof Any)) { + return ConfigOrError.fromError("Invalid config type: " + rawProtoMessage.getClass()); + } + return ConfigOrError.fromConfig(new ExtAuthzFilterConfigOverride()); + } + } + + private final StubManager stubManager; + private final ThreadSafeRandom random; + private final BufferingAuthzClientCall.Factory bufferingAuthzClientCallFactory; + private final ExtAuthzCertificateProvider certificateProvider; + private final CheckRequestBuilder.Factory checkRequestBuilderFactory; + private final CheckResponseHandler.Factory checkResponseHandlerFactory; + private final ExtAuthzClientInterceptor.Factory extAuthzClientInterceptorFactory; + private final ExtAuthzServerInterceptor.Factory extAuthzServerInterceptorFactory; + private final HeaderMutationFilter.Factory headerMutationFilterFactory; + private final HeaderMutator headerMutator; + + + ExtAuthzFilter(StubManager stubManager, ThreadSafeRandom random, + BufferingAuthzClientCall.Factory bufferingAuthzClientCallFactory, + ExtAuthzCertificateProvider certificateProvider, + CheckRequestBuilder.Factory checkRequestBuilderFactory, + CheckResponseHandler.Factory checkResponseHandlerFactory, + ExtAuthzClientInterceptor.Factory extAuthzClientInterceptorFactory, + ExtAuthzServerInterceptor.Factory extAuthzServerInterceptorFactory, + HeaderMutationFilter.Factory headerMutationFilterFactory, HeaderMutator headerMutator) { + this.stubManager = stubManager; + this.random = random; + this.bufferingAuthzClientCallFactory = bufferingAuthzClientCallFactory; + this.certificateProvider = certificateProvider; + this.checkRequestBuilderFactory = checkRequestBuilderFactory; + this.checkResponseHandlerFactory = checkResponseHandlerFactory; + this.extAuthzClientInterceptorFactory = extAuthzClientInterceptorFactory; + this.extAuthzServerInterceptorFactory = extAuthzServerInterceptorFactory; + this.headerMutationFilterFactory = headerMutationFilterFactory; + this.headerMutator = headerMutator; + } + + @Nullable + @Override + public ClientInterceptor buildClientInterceptor(FilterConfig config, + @Nullable FilterConfig overrideConfig, ScheduledExecutorService scheduler) { + if (overrideConfig != null) { + return null; + } + if (!(config instanceof ExtAuthzFilterConfig)) { + return null; + } + ExtAuthzFilterConfig extAuthzFilterConfig = (ExtAuthzFilterConfig) config; + AuthorizationGrpc.AuthorizationStub stub = + stubManager.getStub(extAuthzFilterConfig.extAuthzConfig()); + ExtAuthzConfig extAuthzConfig = extAuthzFilterConfig.extAuthzConfig(); + return extAuthzClientInterceptorFactory.create(extAuthzConfig, stub, + random, bufferingAuthzClientCallFactory, + checkRequestBuilderFactory.create(extAuthzConfig, certificateProvider), + checkResponseHandlerFactory.create(headerMutator, + headerMutationFilterFactory.create(extAuthzConfig.decoderHeaderMutationRules()), + extAuthzConfig), + headerMutator); + } + + @Nullable + @Override + public ServerInterceptor buildServerInterceptor(FilterConfig config, + @Nullable FilterConfig overrideConfig) { + if (overrideConfig != null) { + return null; + } + if (!(config instanceof ExtAuthzFilterConfig)) { + return null; + } + ExtAuthzFilterConfig extAuthzFilterConfig = (ExtAuthzFilterConfig) config; + AuthorizationGrpc.AuthorizationStub stub = + stubManager.getStub(extAuthzFilterConfig.extAuthzConfig()); + ExtAuthzConfig extAuthzConfig = extAuthzFilterConfig.extAuthzConfig(); + return extAuthzServerInterceptorFactory.create(extAuthzConfig, stub, random, + checkRequestBuilderFactory.create(extAuthzConfig, certificateProvider), + checkResponseHandlerFactory.create(headerMutator, + headerMutationFilterFactory.create(extAuthzConfig.decoderHeaderMutationRules()), + extAuthzConfig), + headerMutator); + } + + @Override + public void close() { + stubManager.close(); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/extauthz/StubManager.java b/xds/src/main/java/io/grpc/xds/internal/extauthz/StubManager.java new file mode 100644 index 00000000000..d5a0698a393 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extauthz/StubManager.java @@ -0,0 +1,114 @@ +/* + * 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.envoyproxy.envoy.service.auth.v3.AuthorizationGrpc; +import io.grpc.ManagedChannel; +import io.grpc.xds.internal.grpcservice.GrpcServiceConfig.GoogleGrpcConfig; +import io.grpc.xds.internal.grpcservice.GrpcServiceConfigChannelFactory; +import java.util.Optional; +import javax.annotation.concurrent.GuardedBy; + +/** + * Manages the lifecycle of the authorization stub. + */ +public interface StubManager { + /** Creates a new instance of {@code StubManager}. */ + static StubManager create(GrpcServiceConfigChannelFactory channelFactory) { + return new StubManagerImpl(channelFactory); + } + + /** + * Returns a stub for the given configuration. + */ + AuthorizationGrpc.AuthorizationStub getStub(ExtAuthzConfig config); + + /** + * Frees underlying resources on shutdown. + */ + public void close(); + + /** + * Default implementation of {@link StubManager}. + */ + final class StubManagerImpl implements StubManager { + + private final GrpcServiceConfigChannelFactory channelFactory; + private final Object lock = new Object(); + + @GuardedBy("lock") + private Optional stubHolder = Optional.empty(); + + private StubManagerImpl(GrpcServiceConfigChannelFactory channelFactory) { // NOPMD + this.channelFactory = channelFactory; + } + + @Override + public AuthorizationGrpc.AuthorizationStub getStub(ExtAuthzConfig config) { + GoogleGrpcConfig googleGrpc = config.grpcService().googleGrpc(); + ChannelKey newChannelKey = + ChannelKey.of(googleGrpc.target(), googleGrpc.hashedChannelCredentials().hash()); + + synchronized (lock) { + if (stubHolder.isPresent() && stubHolder.get().channelKey().equals(newChannelKey)) { + return stubHolder.get().stub(); + } + Optional oldChannel = stubHolder.map(StubHolder::channel); + ManagedChannel newChannel = channelFactory.createChannel(config.grpcService()); + stubHolder = Optional.of( + StubHolder.create(newChannelKey, newChannel, AuthorizationGrpc.newStub(newChannel))); + oldChannel.ifPresent(ManagedChannel::shutdown); + return stubHolder.get().stub(); + } + } + + @AutoValue + abstract static class ChannelKey { + static ChannelKey of(String target, int hash) { + return new AutoValue_StubManager_StubManagerImpl_ChannelKey(target, hash); + } + + abstract String target(); + + abstract int hash(); + } + + @AutoValue + abstract static class StubHolder { + static StubHolder create(ChannelKey channelKey, ManagedChannel channel, + AuthorizationGrpc.AuthorizationStub stub) { + return new AutoValue_StubManager_StubManagerImpl_StubHolder(channelKey, channel, stub); + } + + abstract ChannelKey channelKey(); + + abstract ManagedChannel channel(); + + abstract AuthorizationGrpc.AuthorizationStub stub(); + } + + @Override + public void close() { + synchronized (lock) { + stubHolder.ifPresent(holder -> { + holder.channel().shutdown(); + }); + } + } + } +} diff --git a/xds/src/test/java/io/grpc/xds/ExtAuthzFilterTest.java b/xds/src/test/java/io/grpc/xds/ExtAuthzFilterTest.java new file mode 100644 index 00000000000..f8073418a8e --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/ExtAuthzFilterTest.java @@ -0,0 +1,298 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.protobuf.Any; +import com.google.protobuf.Empty; +import io.envoyproxy.envoy.config.core.v3.GrpcService; +import io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz; +import io.envoyproxy.envoy.extensions.grpc_service.call_credentials.access_token.v3.AccessTokenCredentials; +import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.google_default.v3.GoogleDefaultCredentials; +import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.insecure.v3.InsecureCredentials; +import io.envoyproxy.envoy.service.auth.v3.AuthorizationGrpc; +import io.grpc.ClientInterceptor; +import io.grpc.ManagedChannel; +import io.grpc.ServerInterceptor; +import io.grpc.xds.ExtAuthzFilter.ExtAuthzFilterConfig; +import io.grpc.xds.ExtAuthzFilter.ExtAuthzFilterConfigOverride; +import io.grpc.xds.internal.extauthz.BufferingAuthzClientCall; +import io.grpc.xds.internal.extauthz.CheckRequestBuilder; +import io.grpc.xds.internal.extauthz.CheckResponseHandler; +import io.grpc.xds.internal.extauthz.ExtAuthzCertificateProvider; +import io.grpc.xds.internal.extauthz.ExtAuthzClientInterceptor; +import io.grpc.xds.internal.extauthz.ExtAuthzConfig; +import io.grpc.xds.internal.extauthz.ExtAuthzParseException; +import io.grpc.xds.internal.extauthz.ExtAuthzServerInterceptor; +import io.grpc.xds.internal.extauthz.StubManager; +import io.grpc.xds.internal.headermutations.HeaderMutationFilter; +import io.grpc.xds.internal.headermutations.HeaderMutator; +import java.util.concurrent.ScheduledExecutorService; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +/** + * Unit tests for {@link ExtAuthzFilter}. + */ +@RunWith(JUnit4.class) +public class ExtAuthzFilterTest { + + @Rule + public final MockitoRule mocks = MockitoJUnit.rule(); + + @Mock + private StubManager mockStubManager; + @Mock + private ThreadSafeRandom mockRandom; + @Mock + private BufferingAuthzClientCall.Factory mockBufferingAuthzClientCallFactory; + @Mock + private ExtAuthzCertificateProvider mockCertificateProvider; + @Mock + private CheckRequestBuilder.Factory mockCheckRequestBuilderFactory; + @Mock + private CheckRequestBuilder mockCheckRequestBuilder; + @Mock + private CheckResponseHandler.Factory mockCheckResponseHandlerFactory; + @Mock + private CheckResponseHandler mockCheckResponseHandler; + @Mock + private ExtAuthzClientInterceptor.Factory mockClientInterceptorFactory; + @Mock + private ExtAuthzServerInterceptor.Factory mockServerInterceptorFactory; + @Mock + private HeaderMutationFilter.Factory mockHeaderMutationFilterFactory; + @Mock + private HeaderMutationFilter mockHeaderMutationFilter; + @Mock + private HeaderMutator mockHeaderMutator; + @Mock + private ManagedChannel mockChannel; + @Mock + private ScheduledExecutorService mockScheduler; + + private ExtAuthzFilter filter; + private final ExtAuthzFilter.Provider provider = new ExtAuthzFilter.Provider(); + private ExtAuthzConfig extAuthzConfig; + private AuthorizationGrpc.AuthorizationStub authzStub; + + @Before + public void setUp() { + authzStub = AuthorizationGrpc.newStub(mockChannel); + filter = new ExtAuthzFilter(mockStubManager, mockRandom, + mockBufferingAuthzClientCallFactory, mockCertificateProvider, + mockCheckRequestBuilderFactory, mockCheckResponseHandlerFactory, + mockClientInterceptorFactory, mockServerInterceptorFactory, mockHeaderMutationFilterFactory, + mockHeaderMutator); + } + + private ExtAuthzConfig buildExtAuthzConfig() throws ExtAuthzParseException { + ExtAuthz extAuthz = ExtAuthz.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder().setTargetUri("authz.service.com") + .addChannelCredentialsPlugin(Any.pack(InsecureCredentials.newBuilder().build())) + .addCallCredentialsPlugin( + Any.pack(AccessTokenCredentials.newBuilder().setToken("fake-token").build())) + .build()) + .build()) + .build(); + return ExtAuthzConfig.fromProto(extAuthz); + } + + @Test + public void buildClientInterceptor_success() throws ExtAuthzParseException { + extAuthzConfig = buildExtAuthzConfig(); + ExtAuthzFilterConfig filterConfig = new ExtAuthzFilterConfig(extAuthzConfig); + when(mockStubManager.getStub(extAuthzConfig)).thenReturn(authzStub); + when(mockCheckRequestBuilderFactory.create(extAuthzConfig, mockCertificateProvider)) + .thenReturn(mockCheckRequestBuilder); + when(mockHeaderMutationFilterFactory.create(any())).thenReturn(mockHeaderMutationFilter); + when(mockCheckResponseHandlerFactory.create(mockHeaderMutator, mockHeaderMutationFilter, + extAuthzConfig)).thenReturn(mockCheckResponseHandler); + ExtAuthzClientInterceptor interceptor = + (ExtAuthzClientInterceptor) ExtAuthzClientInterceptor.INSTANCE.create(null, null, null, + null, null, null, null); + when(mockClientInterceptorFactory.create(extAuthzConfig, authzStub, mockRandom, + mockBufferingAuthzClientCallFactory, mockCheckRequestBuilder, mockCheckResponseHandler, + mockHeaderMutator)).thenReturn(interceptor); + + ClientInterceptor created = filter.buildClientInterceptor(filterConfig, null, mockScheduler); + assertThat(created).isSameInstanceAs(interceptor); + } + + @Test + public void buildClientInterceptor_withOverride_returnsNull() throws ExtAuthzParseException { + extAuthzConfig = buildExtAuthzConfig(); + ClientInterceptor interceptor = + filter.buildClientInterceptor(new ExtAuthzFilterConfig(extAuthzConfig), + new ExtAuthzFilterConfigOverride(), mockScheduler); + assertThat(interceptor).isNull(); + } + + @Test + public void buildClientInterceptor_wrongConfigType_returnsNull() { + ClientInterceptor interceptor = + filter.buildClientInterceptor(mock(Filter.FilterConfig.class), null, mockScheduler); + assertThat(interceptor).isNull(); + } + + @Test + public void buildServerInterceptor_success() throws ExtAuthzParseException { + extAuthzConfig = buildExtAuthzConfig(); + ExtAuthzFilterConfig filterConfig = new ExtAuthzFilterConfig(extAuthzConfig); + when(mockStubManager.getStub(extAuthzConfig)).thenReturn(authzStub); + when(mockCheckRequestBuilderFactory.create(extAuthzConfig, mockCertificateProvider)) + .thenReturn(mockCheckRequestBuilder); + when(mockHeaderMutationFilterFactory.create(any())).thenReturn(mockHeaderMutationFilter); + when(mockCheckResponseHandlerFactory.create(mockHeaderMutator, mockHeaderMutationFilter, + extAuthzConfig)).thenReturn(mockCheckResponseHandler); + ExtAuthzServerInterceptor interceptor = + (ExtAuthzServerInterceptor) ExtAuthzServerInterceptor.INSTANCE.create(null, null, null, + null, null, null); + when(mockServerInterceptorFactory.create(extAuthzConfig, authzStub, mockRandom, + mockCheckRequestBuilder, mockCheckResponseHandler, mockHeaderMutator)) + .thenReturn(interceptor); + + ServerInterceptor created = filter.buildServerInterceptor(filterConfig, null); + assertThat(created).isSameInstanceAs(interceptor); + } + + @Test + public void buildServerInterceptor_withOverride_returnsNull() throws ExtAuthzParseException { + extAuthzConfig = buildExtAuthzConfig(); + ServerInterceptor interceptor = filter.buildServerInterceptor( + new ExtAuthzFilterConfig(extAuthzConfig), new ExtAuthzFilterConfigOverride()); + assertThat(interceptor).isNull(); + } + + @Test + public void buildServerInterceptor_wrongConfigType_returnsNull() { + ServerInterceptor interceptor = + filter.buildServerInterceptor(mock(Filter.FilterConfig.class), null); + assertThat(interceptor).isNull(); + } + + @Test + public void close_shouldCloseStubManager() { + filter.close(); + verify(mockStubManager).close(); + } + + @Test + public void provider_typeUrls() { + assertThat(provider.typeUrls()).asList().containsExactly( + "type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz", + "type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute"); + } + + @Test + public void provider_isClientAndServerFilter() { + assertThat(provider.isClientFilter()).isTrue(); + assertThat(provider.isServerFilter()).isTrue(); + } + + @Test + public void provider_newInstance() { + ExtAuthzFilter instance = provider.newInstance("test-filter"); + assertThat(instance).isNotNull(); + } + + @Test + public void provider_parseFilterConfig_success() { + + Any googleDefaultChannelCreds = Any.pack(GoogleDefaultCredentials.newBuilder().build()); + Any fakeAccessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("fake-token").build()); + ExtAuthz extAuthz = ExtAuthz.newBuilder() + .setGrpcService(io.envoyproxy.envoy.config.core.v3.GrpcService.newBuilder() + .setGoogleGrpc(io.envoyproxy.envoy.config.core.v3.GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("authz.service.com") + .addChannelCredentialsPlugin(googleDefaultChannelCreds) + .addCallCredentialsPlugin(fakeAccessTokenCreds).build()) + .build()) + .setStatusOnError( + io.envoyproxy.envoy.type.v3.HttpStatus.newBuilder().setCodeValue(403).build()) + .build(); + Any anyProto = Any.pack(extAuthz); + ConfigOrError result = provider.parseFilterConfig(anyProto); + assertThat(result.config).isNotNull(); + assertThat(result.errorDetail).isNull(); + assertThat(result.config.extAuthzConfig().grpcService().googleGrpc().target()) + .isEqualTo("authz.service.com"); + } + + @Test + public void provider_parseFilterConfig_invalidProto() { + Any anyProto = Any.pack(Empty.getDefaultInstance()); + + ConfigOrError result = provider.parseFilterConfig(anyProto); + + assertThat(result.config).isNull(); + assertThat(result.errorDetail).contains("Invalid proto"); + } + + @Test + public void provider_parseFilterConfig_notAny() { + ConfigOrError result = + provider.parseFilterConfig(Empty.getDefaultInstance()); + assertThat(result.config).isNull(); + assertThat(result.errorDetail).contains("Invalid config type"); + } + + @Test + public void provider_parseFilterConfigOverride_success() { + Any anyProto = Any.pack(ExtAuthz.getDefaultInstance()); + ConfigOrError result = + provider.parseFilterConfigOverride(anyProto); + assertThat(result.config).isNotNull(); + assertThat(result.errorDetail).isNull(); + } + + @Test + public void provider_parseFilterConfigOverride_notAny() { + ConfigOrError result = + provider.parseFilterConfigOverride(Empty.getDefaultInstance()); + assertThat(result.config).isNull(); + assertThat(result.errorDetail).contains("Invalid config type"); + } + + @Test + public void extAuthzFilterConfig_typeUrl() throws ExtAuthzParseException { + extAuthzConfig = buildExtAuthzConfig(); + ExtAuthzFilterConfig config = new ExtAuthzFilterConfig(extAuthzConfig); + assertThat(config.typeUrl()) + .isEqualTo("type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz"); + } + + @Test + public void extAuthzFilterConfigOverride_typeUrl() { + ExtAuthzFilterConfigOverride override = new ExtAuthzFilterConfigOverride(); + assertThat(override.typeUrl()).isEqualTo( + "type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute"); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/extauthz/StubManagerTest.java b/xds/src/test/java/io/grpc/xds/internal/extauthz/StubManagerTest.java new file mode 100644 index 00000000000..6a60f0bf142 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/extauthz/StubManagerTest.java @@ -0,0 +1,152 @@ +/* + * 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.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.protobuf.Any; +import io.envoyproxy.envoy.config.core.v3.GrpcService; +import io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz; +import io.envoyproxy.envoy.extensions.grpc_service.call_credentials.access_token.v3.AccessTokenCredentials; +import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.insecure.v3.InsecureCredentials; +import io.envoyproxy.envoy.service.auth.v3.AuthorizationGrpc; +import io.grpc.ManagedChannel; +import io.grpc.xds.internal.grpcservice.GrpcServiceConfigChannelFactory; +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; + +@RunWith(JUnit4.class) +public class StubManagerTest { + + @Rule + public final MockitoRule mockito = MockitoJUnit.rule(); + + @Mock + private GrpcServiceConfigChannelFactory channelFactory; + @Mock + private ManagedChannel channel1; + @Mock + private ManagedChannel channel2; + + private StubManager stubManager; + private ExtAuthzConfig config1; + private ExtAuthzConfig config2; + + @Before + public void setUp() throws ExtAuthzParseException { + stubManager = StubManager.create(channelFactory); + config1 = buildExtAuthzConfig("target1"); + config2 = buildExtAuthzConfig("target2"); + + when(channelFactory.createChannel(config1.grpcService())).thenReturn(channel1); + when(channelFactory.createChannel(config2.grpcService())).thenReturn(channel2); + } + + private ExtAuthzConfig buildExtAuthzConfig(String targetUri) throws ExtAuthzParseException { + ExtAuthz extAuthz = ExtAuthz.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder().setTargetUri(targetUri) + .addChannelCredentialsPlugin(Any.pack(InsecureCredentials.newBuilder().build())) + .addCallCredentialsPlugin( + Any.pack(AccessTokenCredentials.newBuilder().setToken("fake-token").build())) + .build()) + .build()) + .build(); + return ExtAuthzConfig.fromProto(extAuthz); + } + + @Test + public void getStub_createsNewStubAndChannel_firstTime() { + AuthorizationGrpc.AuthorizationStub stub = stubManager.getStub(config1); + assertThat(stub).isNotNull(); + verify(channelFactory).createChannel(config1.grpcService()); + } + + @Test + public void getStub_returnsExistingStub_sameConfig() throws ExtAuthzParseException { + AuthorizationGrpc.AuthorizationStub stub1 = stubManager.getStub(config1); + ExtAuthzConfig sameAsConfig1 = buildExtAuthzConfig("target1"); + AuthorizationGrpc.AuthorizationStub stub2 = stubManager.getStub(sameAsConfig1); + + assertThat(stub1).isSameInstanceAs(stub2); + verify(channelFactory, times(1)).createChannel(config1.grpcService()); + } + + @Test + public void getStub_createsNewStubAndShutsDownOld_differentConfig() { + AuthorizationGrpc.AuthorizationStub stub1 = stubManager.getStub(config1); + AuthorizationGrpc.AuthorizationStub stub2 = stubManager.getStub(config2); + + assertThat(stub1).isNotSameInstanceAs(stub2); + verify(channelFactory).createChannel(config1.grpcService()); + verify(channelFactory).createChannel(config2.grpcService()); + verify(channel1).shutdown(); + verify(channel2, never()).shutdown(); + } + + @Test + public void getStub_createsNewStubAndShutsDownOld_differentTarget() + throws ExtAuthzParseException { + config2 = buildExtAuthzConfig("target1-different"); + when(channelFactory.createChannel(config2.grpcService())).thenReturn(channel2); + + AuthorizationGrpc.AuthorizationStub stub1 = stubManager.getStub(config1); + AuthorizationGrpc.AuthorizationStub stub2 = stubManager.getStub(config2); + + assertThat(stub1).isNotSameInstanceAs(stub2); + verify(channelFactory).createChannel(config1.grpcService()); + verify(channelFactory).createChannel(config2.grpcService()); + verify(channel1).shutdown(); + } + + @Test + public void getStub_createsNewStubAndShutsDownOld_differentCredentialsHash() + throws ExtAuthzParseException { + when(channelFactory.createChannel(config2.grpcService())).thenReturn(channel2); + + AuthorizationGrpc.AuthorizationStub stub1 = stubManager.getStub(config1); + AuthorizationGrpc.AuthorizationStub stub2 = stubManager.getStub(config2); + + assertThat(stub1).isNotSameInstanceAs(stub2); + verify(channelFactory).createChannel(config1.grpcService()); + verify(channelFactory).createChannel(config2.grpcService()); + verify(channel1).shutdown(); + } + + @Test + public void close_shutsDownChannel() { + stubManager.getStub(config1); + stubManager.close(); + verify(channel1).shutdown(); + } + + @Test + public void close_noStubCreated_doesNothing() { + stubManager.close(); + verify(channel1, never()).shutdown(); + } +}