diff --git a/api/envoy/extensions/filters/http/ext_authz/v3/ext_authz.proto b/api/envoy/extensions/filters/http/ext_authz/v3/ext_authz.proto index 0a2492b2ff5f5..690a9956454df 100644 --- a/api/envoy/extensions/filters/http/ext_authz/v3/ext_authz.proto +++ b/api/envoy/extensions/filters/http/ext_authz/v3/ext_authz.proto @@ -476,6 +476,7 @@ message ExtAuthzPerRoute { } // 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"; @@ -513,4 +514,16 @@ message CheckSettings { // :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/changelogs/current.yaml b/changelogs/current.yaml index 1335481ea7766..c272db6332ffd 100644 --- a/changelogs/current.yaml +++ b/changelogs/current.yaml @@ -191,9 +191,8 @@ new_features: for more details. - area: socket change: | - Added :ref:``network_namespace_filepath `` to - :ref:`SocketAddress `. This field allows specifying a Linux network namespace filepath - for socket creation, enabling network isolation in containerized environments. + Added ``network_namespace_filepath`` to :ref:`SocketAddress `. This field allows + specifying a Linux network namespace filepath for socket creation, enabling network isolation in containerized environments. - area: ratelimit change: | Add the :ref:`rate_limits @@ -244,6 +243,13 @@ new_features: Added ``virtualHost()`` to the Stream handle API, allowing Lua scripts to retrieve virtual host information. So far, the only method implemented is ``metadata()``, allowing Lua scripts to access virtual host metadata scoped to the specific filter name. See :ref:`Virtual host object API ` for more details. +- area: ext_authz + change: | + Added support for per-route gRPC service override in the ``ext_authz`` HTTP filter. This allows different routes + to use different external authorization backends by configuring a + :ref:`grpc_service ` + in the per-route ``check_settings``. Routes without this configuration continue to use the default + authorization service. - area: tracing change: | Added :ref:`trace_context_option ` enum diff --git a/source/extensions/filters/common/ext_authz/ext_authz_http_impl.cc b/source/extensions/filters/common/ext_authz/ext_authz_http_impl.cc index 070636df941af..170ee16ef84a8 100644 --- a/source/extensions/filters/common/ext_authz/ext_authz_http_impl.cc +++ b/source/extensions/filters/common/ext_authz/ext_authz_http_impl.cc @@ -133,6 +133,30 @@ ClientConfig::ClientConfig(const envoy::extensions::filters::http::ext_authz::v3 Router::HeaderParserPtr)), encode_raw_headers_(config.encode_raw_headers()) {} +ClientConfig::ClientConfig( + const envoy::extensions::filters::http::ext_authz::v3::HttpService& http_service, + bool encode_raw_headers, uint32_t timeout, Server::Configuration::CommonFactoryContext& context) + : client_header_matchers_(toClientMatchers( + http_service.authorization_response().allowed_client_headers(), context)), + client_header_on_success_matchers_(toClientMatchersOnSuccess( + http_service.authorization_response().allowed_client_headers_on_success(), context)), + to_dynamic_metadata_matchers_(toDynamicMetadataMatchers( + http_service.authorization_response().dynamic_metadata_from_headers(), context)), + upstream_header_matchers_(toUpstreamMatchers( + http_service.authorization_response().allowed_upstream_headers(), context)), + upstream_header_to_append_matchers_(toUpstreamMatchers( + http_service.authorization_response().allowed_upstream_headers_to_append(), context)), + cluster_name_(http_service.server_uri().cluster()), timeout_(timeout), + path_prefix_( + THROW_OR_RETURN_VALUE(validatePathPrefix(http_service.path_prefix()), std::string)), + tracing_name_(fmt::format("async {} egress", http_service.server_uri().cluster())), + request_headers_parser_(THROW_OR_RETURN_VALUE( + Router::HeaderParser::configure( + http_service.authorization_request().headers_to_add(), + envoy::config::core::v3::HeaderValueOption::OVERWRITE_IF_EXISTS_OR_ADD), + Router::HeaderParserPtr)), + encode_raw_headers_(encode_raw_headers) {} + MatcherSharedPtr ClientConfig::toClientMatchersOnSuccess(const envoy::type::matcher::v3::ListStringMatcher& list, Server::Configuration::CommonFactoryContext& context) { diff --git a/source/extensions/filters/common/ext_authz/ext_authz_http_impl.h b/source/extensions/filters/common/ext_authz/ext_authz_http_impl.h index d79b34876548d..0847afe6c5a14 100644 --- a/source/extensions/filters/common/ext_authz/ext_authz_http_impl.h +++ b/source/extensions/filters/common/ext_authz/ext_authz_http_impl.h @@ -28,6 +28,11 @@ class ClientConfig { uint32_t timeout, absl::string_view path_prefix, Server::Configuration::CommonFactoryContext& context); + // Build config directly from HttpService without constructing a temporary ExtAuthz. + ClientConfig(const envoy::extensions::filters::http::ext_authz::v3::HttpService& http_service, + bool encode_raw_headers, uint32_t timeout, + Server::Configuration::CommonFactoryContext& context); + /** * Returns the name of the authorization cluster. */ diff --git a/source/extensions/filters/http/ext_authz/config.cc b/source/extensions/filters/http/ext_authz/config.cc index 83a3c60287ae6..8f7febefa8ccc 100644 --- a/source/extensions/filters/http/ext_authz/config.cc +++ b/source/extensions/filters/http/ext_authz/config.cc @@ -39,7 +39,8 @@ Http::FilterFactoryCb ExtAuthzFilterConfig::createFilterFactoryFromProtoWithServ &server_context](Http::FilterChainFactoryCallbacks& callbacks) { auto client = std::make_unique( server_context.clusterManager(), client_config); - callbacks.addStreamFilter(std::make_shared(filter_config, std::move(client))); + callbacks.addStreamFilter( + std::make_shared(filter_config, std::move(client), server_context)); }; } else { // gRPC client. @@ -57,7 +58,8 @@ Http::FilterFactoryCb ExtAuthzFilterConfig::createFilterFactoryFromProtoWithServ THROW_IF_NOT_OK_REF(client_or_error.status()); auto client = std::make_unique( client_or_error.value(), std::chrono::milliseconds(timeout_ms)); - callbacks.addStreamFilter(std::make_shared(filter_config, std::move(client))); + callbacks.addStreamFilter( + std::make_shared(filter_config, std::move(client), server_context)); }; } return callback; diff --git a/source/extensions/filters/http/ext_authz/ext_authz.cc b/source/extensions/filters/http/ext_authz/ext_authz.cc index 9efd5b6fc36f6..7fd7abf65f365 100644 --- a/source/extensions/filters/http/ext_authz/ext_authz.cc +++ b/source/extensions/filters/http/ext_authz/ext_authz.cc @@ -1,4 +1,3 @@ -#include "ext_authz.h" #include "source/extensions/filters/http/ext_authz/ext_authz.h" #include @@ -21,6 +20,9 @@ namespace ExtAuthz { namespace { +// Default timeout for per-route gRPC client creation. +constexpr uint32_t kDefaultPerRouteTimeoutMs = 200; + using MetadataProto = ::envoy::config::core::v3::Metadata; using Filters::Common::MutationRules::CheckOperation; using Filters::Common::MutationRules::CheckResult; @@ -172,6 +174,86 @@ void FilterConfigPerRoute::merge(const FilterConfigPerRoute& other) { } } +// Constructor used for merging configurations from different levels (vhost, route, etc.) +FilterConfigPerRoute::FilterConfigPerRoute(const FilterConfigPerRoute& less_specific, + const FilterConfigPerRoute& more_specific) + : context_extensions_(less_specific.context_extensions_), + check_settings_(more_specific.check_settings_), disabled_(more_specific.disabled_), + // Only use the most specific per-route override. Do not inherit overrides from less + // specific configuration. If the more specific configuration has no override, leave both + // unset so that the main filter configuration is used. + grpc_service_(more_specific.grpc_service_.has_value() ? more_specific.grpc_service_ + : absl::nullopt), + http_service_(more_specific.http_service_.has_value() ? more_specific.http_service_ + : absl::nullopt) { + // Merge context extensions from more specific configuration, overriding less specific ones. + for (const auto& extension : more_specific.context_extensions_) { + context_extensions_[extension.first] = extension.second; + } +} + +Filters::Common::ExtAuthz::ClientPtr +Filter::createPerRouteGrpcClient(const envoy::config::core::v3::GrpcService& grpc_service) { + if (server_context_ == nullptr) { + ENVOY_STREAM_LOG( + debug, "ext_authz filter: server context not available for per-route gRPC client creation.", + *decoder_callbacks_); + return nullptr; + } + + // Use the timeout from the gRPC service configuration, use default if not specified. + const uint32_t timeout_ms = + PROTOBUF_GET_MS_OR_DEFAULT(grpc_service, timeout, kDefaultPerRouteTimeoutMs); + + // We can skip transport version check for per-route gRPC service here. + // The transport version is already validated at the main configuration level. + Envoy::Grpc::GrpcServiceConfigWithHashKey config_with_hash_key = + Envoy::Grpc::GrpcServiceConfigWithHashKey(grpc_service); + + auto client_or_error = server_context_->clusterManager() + .grpcAsyncClientManager() + .getOrCreateRawAsyncClientWithHashKey(config_with_hash_key, + server_context_->scope(), true); + if (!client_or_error.ok()) { + ENVOY_STREAM_LOG(warn, + "ext_authz filter: failed to create per-route gRPC client: {}. Falling back " + "to default client.", + *decoder_callbacks_, client_or_error.status().ToString()); + return nullptr; + } + + ENVOY_STREAM_LOG(debug, "ext_authz filter: created per-route gRPC client for cluster: {}.", + *decoder_callbacks_, + grpc_service.has_envoy_grpc() ? grpc_service.envoy_grpc().cluster_name() + : "google_grpc"); + + return std::make_unique( + client_or_error.value(), std::chrono::milliseconds(timeout_ms)); +} + +Filters::Common::ExtAuthz::ClientPtr Filter::createPerRouteHttpClient( + const envoy::extensions::filters::http::ext_authz::v3::HttpService& http_service) { + if (server_context_ == nullptr) { + ENVOY_STREAM_LOG( + debug, "ext_authz filter: server context not available for per-route HTTP client creation.", + *decoder_callbacks_); + return nullptr; + } + + // Use the timeout from the HTTP service configuration, use default if not specified. + const uint32_t timeout_ms = + PROTOBUF_GET_MS_OR_DEFAULT(http_service.server_uri(), timeout, kDefaultPerRouteTimeoutMs); + + ENVOY_STREAM_LOG(debug, "ext_authz filter: creating per-route HTTP client for URI: {}.", + *decoder_callbacks_, http_service.server_uri().uri()); + + const auto client_config = std::make_shared( + http_service, config_->headersAsBytes(), timeout_ms, *server_context_); + + return std::make_unique( + server_context_->clusterManager(), client_config); +} + void Filter::initiateCall(const Http::RequestHeaderMap& headers) { if (filter_return_ == FilterReturn::StopDecoding) { return; @@ -205,9 +287,10 @@ void Filter::initiateCall(const Http::RequestHeaderMap& headers) { for (const FilterConfigPerRoute& cfg : Http::Utility::getAllPerFilterConfig(decoder_callbacks_)) { if (maybe_merged_per_route_config.has_value()) { - maybe_merged_per_route_config.value().merge(cfg); + FilterConfigPerRoute current_config = maybe_merged_per_route_config.value(); + maybe_merged_per_route_config.emplace(current_config, cfg); } else { - maybe_merged_per_route_config = cfg; + maybe_merged_per_route_config.emplace(cfg); } } @@ -216,6 +299,46 @@ void Filter::initiateCall(const Http::RequestHeaderMap& headers) { context_extensions = maybe_merged_per_route_config.value().takeContextExtensions(); } + // Check if we need to use a per-route service override (gRPC or HTTP). + Filters::Common::ExtAuthz::Client* client_to_use = client_.get(); + if (maybe_merged_per_route_config) { + if (maybe_merged_per_route_config->grpcService().has_value()) { + const auto& grpc_service = maybe_merged_per_route_config->grpcService().value(); + ENVOY_STREAM_LOG(debug, "ext_authz filter: using per-route gRPC service configuration.", + *decoder_callbacks_); + + // Create a new gRPC client for this route. + per_route_client_ = createPerRouteGrpcClient(grpc_service); + if (per_route_client_ != nullptr) { + client_to_use = per_route_client_.get(); + ENVOY_STREAM_LOG(debug, "ext_authz filter: successfully created per-route gRPC client.", + *decoder_callbacks_); + } else { + ENVOY_STREAM_LOG( + warn, + "ext_authz filter: failed to create per-route gRPC client, falling back to default.", + *decoder_callbacks_); + } + } else if (maybe_merged_per_route_config->httpService().has_value()) { + const auto& http_service = maybe_merged_per_route_config->httpService().value(); + ENVOY_STREAM_LOG(debug, "ext_authz filter: using per-route HTTP service configuration.", + *decoder_callbacks_); + + // Create a new HTTP client for this route. + per_route_client_ = createPerRouteHttpClient(http_service); + if (per_route_client_ != nullptr) { + client_to_use = per_route_client_.get(); + ENVOY_STREAM_LOG(debug, "ext_authz filter: successfully created per-route HTTP client.", + *decoder_callbacks_); + } else { + ENVOY_STREAM_LOG( + warn, + "ext_authz filter: failed to create per-route HTTP client, falling back to default.", + *decoder_callbacks_); + } + } + } + // If metadata_context_namespaces or typed_metadata_context_namespaces is specified, // pass matching filter metadata to the ext_authz service. // If metadata key is set in both the connection and request metadata, @@ -241,7 +364,7 @@ void Filter::initiateCall(const Http::RequestHeaderMap& headers) { config_->destinationLabels(), config_->allowedHeadersMatcher(), config_->disallowedHeadersMatcher()); - ENVOY_STREAM_LOG(trace, "ext_authz filter calling authorization server", *decoder_callbacks_); + ENVOY_STREAM_LOG(trace, "ext_authz filter calling authorization server.", *decoder_callbacks_); // Store start time of ext_authz filter call start_time_ = decoder_callbacks_->dispatcher().timeSource().monotonicTime(); @@ -250,8 +373,8 @@ void Filter::initiateCall(const Http::RequestHeaderMap& headers) { // going to invoke check call. cluster_ = decoder_callbacks_->clusterInfo(); initiating_call_ = true; - client_->check(*this, check_request_, decoder_callbacks_->activeSpan(), - decoder_callbacks_->streamInfo()); + client_to_use->check(*this, check_request_, decoder_callbacks_->activeSpan(), + decoder_callbacks_->streamInfo()); initiating_call_ = false; } diff --git a/source/extensions/filters/http/ext_authz/ext_authz.h b/source/extensions/filters/http/ext_authz/ext_authz.h index 3f309b97c6d2e..123daee1a6154 100644 --- a/source/extensions/filters/http/ext_authz/ext_authz.h +++ b/source/extensions/filters/http/ext_authz/ext_authz.h @@ -6,8 +6,10 @@ #include #include "envoy/extensions/filters/http/ext_authz/v3/ext_authz.pb.h" +#include "envoy/grpc/async_client_manager.h" #include "envoy/http/filter.h" #include "envoy/runtime/runtime.h" +#include "envoy/server/factory_context.h" #include "envoy/service/auth/v3/external_auth.pb.h" #include "envoy/stats/scope.h" #include "envoy/stats/stats_macros.h" @@ -17,6 +19,7 @@ #include "source/common/common/logger.h" #include "source/common/common/matchers.h" #include "source/common/common/utility.h" +#include "source/common/grpc/typed_async_client.h" #include "source/common/http/codes.h" #include "source/common/http/header_map_impl.h" #include "source/common/runtime/runtime_protos.h" @@ -305,7 +308,13 @@ class FilterConfigPerRoute : public Router::RouteSpecificFilterConfig { check_settings_(config.has_check_settings() ? config.check_settings() : envoy::extensions::filters::http::ext_authz::v3::CheckSettings()), - disabled_(config.disabled()) { + disabled_(config.disabled()), + grpc_service_(config.has_check_settings() && config.check_settings().has_grpc_service() + ? absl::make_optional(config.check_settings().grpc_service()) + : absl::nullopt), + http_service_(config.has_check_settings() && config.check_settings().has_http_service() + ? absl::make_optional(config.check_settings().http_service()) + : absl::nullopt) { if (config.has_check_settings() && config.check_settings().disable_request_body_buffering() && config.check_settings().has_with_request_body()) { ExceptionUtil::throwEnvoyException( @@ -314,6 +323,12 @@ class FilterConfigPerRoute : public Router::RouteSpecificFilterConfig { } } + // This constructor is used as a way to merge more-specific config into less-specific config in a + // clearly defined way (e.g. route config into VH config). All fields on this class must be const + // and thus must be initialized in the constructor initialization list. + FilterConfigPerRoute(const FilterConfigPerRoute& less_specific, + const FilterConfigPerRoute& more_specific); + void merge(const FilterConfigPerRoute& other); /** @@ -329,12 +344,30 @@ class FilterConfigPerRoute : public Router::RouteSpecificFilterConfig { return check_settings_; } + /** + * @return The gRPC service override for this route, if any. + */ + const absl::optional& grpcService() const { + return grpc_service_; + } + + /** + * @return The HTTP service override for this route, if any. + */ + const absl::optional& + httpService() const { + return http_service_; + } + private: // We save the context extensions as a protobuf map instead of a std::map as this allows us to // move it to the CheckRequest, thus avoiding a copy that would incur by converting it. ContextExtensionsMap context_extensions_; envoy::extensions::filters::http::ext_authz::v3::CheckSettings check_settings_; - bool disabled_; + const bool disabled_; + const absl::optional grpc_service_; + const absl::optional + http_service_; }; /** @@ -348,6 +381,12 @@ class Filter : public Logger::Loggable, Filter(const FilterConfigSharedPtr& config, Filters::Common::ExtAuthz::ClientPtr&& client) : config_(config), client_(std::move(client)), stats_(config->stats()) {} + // Constructor that includes server context for per-route service support. + Filter(const FilterConfigSharedPtr& config, Filters::Common::ExtAuthz::ClientPtr&& client, + Server::Configuration::ServerFactoryContext& server_context) + : config_(config), client_(std::move(client)), server_context_(&server_context), + stats_(config->stats()) {} + // Http::StreamFilterBase void onDestroy() override; @@ -383,6 +422,14 @@ class Filter : public Logger::Loggable, // code. void rejectResponse(); + // Create a new gRPC client for per-route gRPC service configuration. + Filters::Common::ExtAuthz::ClientPtr + createPerRouteGrpcClient(const envoy::config::core::v3::GrpcService& grpc_service); + + // Create a new HTTP client for per-route HTTP service configuration. + Filters::Common::ExtAuthz::ClientPtr createPerRouteHttpClient( + const envoy::extensions::filters::http::ext_authz::v3::HttpService& http_service); + absl::optional start_time_; void addResponseHeaders(Http::HeaderMap& header_map, const Http::HeaderVector& headers); void initiateCall(const Http::RequestHeaderMap& headers); @@ -410,6 +457,10 @@ class Filter : public Logger::Loggable, Http::HeaderMapPtr getHeaderMap(const Filters::Common::ExtAuthz::ResponsePtr& response); FilterConfigSharedPtr config_; Filters::Common::ExtAuthz::ClientPtr client_; + // Per-route gRPC client that overrides the default client when specified. + Filters::Common::ExtAuthz::ClientPtr per_route_client_; + // Server context for creating per-route clients. + Server::Configuration::ServerFactoryContext* server_context_{nullptr}; Http::StreamDecoderFilterCallbacks* decoder_callbacks_{}; Http::StreamEncoderFilterCallbacks* encoder_callbacks_{}; Http::RequestHeaderMap* request_headers_; diff --git a/test/extensions/filters/common/ext_authz/ext_authz_http_impl_test.cc b/test/extensions/filters/common/ext_authz/ext_authz_http_impl_test.cc index 298abb58a10e0..92cc6cddc5e62 100644 --- a/test/extensions/filters/common/ext_authz/ext_authz_http_impl_test.cc +++ b/test/extensions/filters/common/ext_authz/ext_authz_http_impl_test.cc @@ -209,6 +209,27 @@ class ExtAuthzHttpClientTest : public testing::Test { NiceMock stream_info_; }; +// Verify ClientConfig could be built directly from HttpService and that the +// fields get wired correctly. +TEST_F(ExtAuthzHttpClientTest, ClientConfigFromHttpService) { + envoy::extensions::filters::http::ext_authz::v3::HttpService http_service; + http_service.mutable_server_uri()->set_uri("ext_authz:9000"); + http_service.mutable_server_uri()->set_cluster("ext_authz"); + http_service.mutable_server_uri()->mutable_timeout()->set_seconds(0); + http_service.set_path_prefix("/prefix"); + // Add one header to add to request to exercise header parser creation. + auto* add = http_service.mutable_authorization_request()->add_headers_to_add(); + add->set_key("x-added"); + add->set_value("v"); + + auto cfg = std::make_shared(http_service, /*encode_raw_headers=*/true, + /*timeout_ms=*/123, factory_context_); + EXPECT_EQ(cfg->cluster(), "ext_authz"); + EXPECT_EQ(cfg->pathPrefix(), "/prefix"); + EXPECT_EQ(cfg->timeout(), std::chrono::milliseconds{123}); + EXPECT_TRUE(cfg->encodeRawHeaders()); +} + TEST_F(ExtAuthzHttpClientTest, StreamInfo) { envoy::service::auth::v3::CheckRequest request; client_->check(request_callbacks_, request, parent_span_, stream_info_); diff --git a/test/extensions/filters/http/ext_authz/config_test.cc b/test/extensions/filters/http/ext_authz/config_test.cc index bfb2a593b4114..9a84ee2e73deb 100644 --- a/test/extensions/filters/http/ext_authz/config_test.cc +++ b/test/extensions/filters/http/ext_authz/config_test.cc @@ -8,6 +8,7 @@ #include "source/common/network/address_impl.h" #include "source/common/thread_local/thread_local_impl.h" #include "source/extensions/filters/http/ext_authz/config.h" +#include "source/extensions/filters/http/ext_authz/ext_authz.h" #include "test/mocks/server/factory_context.h" #include "test/test_common/real_threads_test_helper.h" @@ -28,6 +29,10 @@ namespace Extensions { namespace HttpFilters { namespace ExtAuthz { +using testing::NiceMock; +using testing::Return; +using testing::ReturnRef; + class TestAsyncClientManagerImpl : public Grpc::AsyncClientManagerImpl { public: TestAsyncClientManagerImpl(Upstream::ClusterManager& cm, ThreadLocal::Instance& tls, @@ -217,6 +222,412 @@ TEST_F(ExtAuthzFilterHttpTest, FilterWithServerContext) { cb(filter_callback); } +TEST_F(ExtAuthzFilterHttpTest, PerRouteGrpcServiceConfiguration) { + const std::string per_route_config_yaml = R"EOF( + check_settings: + context_extensions: + virtual_host: "my_virtual_host" + route_type: "high_qps" + grpc_service: + envoy_grpc: + cluster_name: "ext_authz_high_qps" + )EOF"; + + ExtAuthzFilterConfig factory; + ProtobufTypes::MessagePtr proto_config = factory.createEmptyRouteConfigProto(); + TestUtility::loadFromYaml(per_route_config_yaml, *proto_config); + + testing::NiceMock context; + EXPECT_CALL(context, messageValidationVisitor()); + auto route_config = factory.createRouteSpecificFilterConfig(*proto_config, context, + context.messageValidationVisitor()); + EXPECT_TRUE(route_config.ok()); + + const auto& typed_config = dynamic_cast(*route_config.value()); + EXPECT_FALSE(typed_config.disabled()); + EXPECT_TRUE(typed_config.grpcService().has_value()); + + const auto& grpc_service = typed_config.grpcService().value(); + EXPECT_TRUE(grpc_service.has_envoy_grpc()); + EXPECT_EQ(grpc_service.envoy_grpc().cluster_name(), "ext_authz_high_qps"); + + const auto& context_extensions = typed_config.contextExtensions(); + EXPECT_EQ(context_extensions.at("virtual_host"), "my_virtual_host"); + EXPECT_EQ(context_extensions.at("route_type"), "high_qps"); +} + +TEST_F(ExtAuthzFilterHttpTest, PerRouteHttpServiceConfiguration) { + const std::string per_route_config_yaml = R"EOF( + check_settings: + context_extensions: + virtual_host: "my_virtual_host" + route_type: "high_qps" + http_service: + server_uri: + uri: "https://ext-authz-http.example.com" + cluster: "ext_authz_http_cluster" + timeout: 2s + path_prefix: "/api/auth" + )EOF"; + + ExtAuthzFilterConfig factory; + ProtobufTypes::MessagePtr proto_config = factory.createEmptyRouteConfigProto(); + TestUtility::loadFromYaml(per_route_config_yaml, *proto_config); + + testing::NiceMock context; + EXPECT_CALL(context, messageValidationVisitor()); + auto route_config = factory.createRouteSpecificFilterConfig(*proto_config, context, + context.messageValidationVisitor()); + EXPECT_TRUE(route_config.ok()); + + const auto& typed_config = dynamic_cast(*route_config.value()); + EXPECT_FALSE(typed_config.disabled()); + EXPECT_TRUE(typed_config.httpService().has_value()); + EXPECT_FALSE(typed_config.grpcService().has_value()); + + const auto& http_service = typed_config.httpService().value(); + EXPECT_EQ(http_service.server_uri().uri(), "https://ext-authz-http.example.com"); + EXPECT_EQ(http_service.server_uri().cluster(), "ext_authz_http_cluster"); + EXPECT_EQ(http_service.server_uri().timeout().seconds(), 2); + EXPECT_EQ(http_service.path_prefix(), "/api/auth"); + + const auto& context_extensions = typed_config.contextExtensions(); + EXPECT_EQ(context_extensions.at("virtual_host"), "my_virtual_host"); + EXPECT_EQ(context_extensions.at("route_type"), "high_qps"); +} + +TEST_F(ExtAuthzFilterHttpTest, PerRouteServiceTypeSwitching) { + // Test that we can switch service types - e.g., have gRPC in less specific and HTTP in more + // specific + const std::string less_specific_config_yaml = R"EOF( + check_settings: + context_extensions: + base_setting: "from_base" + grpc_service: + envoy_grpc: + cluster_name: "ext_authz_grpc_cluster" + )EOF"; + + const std::string more_specific_config_yaml = R"EOF( + check_settings: + context_extensions: + override_setting: "from_override" + http_service: + server_uri: + uri: "https://ext-authz-http.example.com" + cluster: "ext_authz_http_cluster" + timeout: 3s + path_prefix: "/auth/check" + )EOF"; + + ExtAuthzFilterConfig factory; + + // Create less specific configuration with gRPC service + ProtobufTypes::MessagePtr less_specific_proto = factory.createEmptyRouteConfigProto(); + TestUtility::loadFromYaml(less_specific_config_yaml, *less_specific_proto); + FilterConfigPerRoute less_specific_config( + *dynamic_cast( + less_specific_proto.get())); + + // Create more specific configuration with HTTP service + ProtobufTypes::MessagePtr more_specific_proto = factory.createEmptyRouteConfigProto(); + TestUtility::loadFromYaml(more_specific_config_yaml, *more_specific_proto); + FilterConfigPerRoute more_specific_config( + *dynamic_cast( + more_specific_proto.get())); + + // Merge configurations - should use HTTP service from more specific config + FilterConfigPerRoute merged_config(less_specific_config, more_specific_config); + + // Verify that HTTP service from more specific config is used (service type switching) + EXPECT_TRUE(merged_config.httpService().has_value()); + EXPECT_FALSE(merged_config.grpcService().has_value()); + + const auto& http_service = merged_config.httpService().value(); + EXPECT_EQ(http_service.server_uri().uri(), "https://ext-authz-http.example.com"); + EXPECT_EQ(http_service.server_uri().cluster(), "ext_authz_http_cluster"); + EXPECT_EQ(http_service.path_prefix(), "/auth/check"); + + // Verify context extensions are properly merged (less specific preserved, more specific + // overrides) + const auto& context_extensions = merged_config.contextExtensions(); + EXPECT_EQ(context_extensions.size(), 2); + EXPECT_EQ(context_extensions.at("base_setting"), "from_base"); + EXPECT_EQ(context_extensions.at("override_setting"), "from_override"); +} + +TEST_F(ExtAuthzFilterHttpTest, PerRouteServiceTypeSwitchingHttpToGrpc) { + // Test that we can switch from HTTP service to gRPC service (reverse of the other test) + const std::string less_specific_config_yaml = R"EOF( + check_settings: + context_extensions: + base_setting: "from_base" + http_service: + server_uri: + uri: "https://ext-authz-http.example.com" + cluster: "ext_authz_http_cluster" + timeout: 1s + path_prefix: "/auth" + )EOF"; + + const std::string more_specific_config_yaml = R"EOF( + check_settings: + context_extensions: + override_setting: "from_override" + grpc_service: + envoy_grpc: + cluster_name: "ext_authz_grpc_cluster" + authority: "ext-authz.example.com" + timeout: 5s + )EOF"; + + ExtAuthzFilterConfig factory; + + // Create less specific configuration with HTTP service + ProtobufTypes::MessagePtr less_specific_proto = factory.createEmptyRouteConfigProto(); + TestUtility::loadFromYaml(less_specific_config_yaml, *less_specific_proto); + FilterConfigPerRoute less_specific_config( + *dynamic_cast( + less_specific_proto.get())); + + // Create more specific configuration with gRPC service + ProtobufTypes::MessagePtr more_specific_proto = factory.createEmptyRouteConfigProto(); + TestUtility::loadFromYaml(more_specific_config_yaml, *more_specific_proto); + FilterConfigPerRoute more_specific_config( + *dynamic_cast( + more_specific_proto.get())); + + // Merge configurations - should use gRPC service from more specific config + FilterConfigPerRoute merged_config(less_specific_config, more_specific_config); + + // Verify that gRPC service from more specific config is used (service type switching) + EXPECT_TRUE(merged_config.grpcService().has_value()); + EXPECT_FALSE(merged_config.httpService().has_value()); + + const auto& grpc_service = merged_config.grpcService().value(); + EXPECT_TRUE(grpc_service.has_envoy_grpc()); + EXPECT_EQ(grpc_service.envoy_grpc().cluster_name(), "ext_authz_grpc_cluster"); + EXPECT_EQ(grpc_service.envoy_grpc().authority(), "ext-authz.example.com"); + EXPECT_EQ(grpc_service.timeout().seconds(), 5); + + // Verify context extensions are properly merged + const auto& context_extensions = merged_config.contextExtensions(); + EXPECT_EQ(context_extensions.size(), 2); + EXPECT_EQ(context_extensions.at("base_setting"), "from_base"); + EXPECT_EQ(context_extensions.at("override_setting"), "from_override"); +} + +TEST_F(ExtAuthzFilterHttpTest, PerRouteHttpServiceWithTimeout) { + // Test HTTP service configuration with custom timeout + const std::string per_route_config_yaml = R"EOF( + check_settings: + http_service: + server_uri: + uri: "https://ext-authz-custom.example.com" + cluster: "ext_authz_custom_cluster" + timeout: 10s + path_prefix: "/custom/auth" + )EOF"; + + ExtAuthzFilterConfig factory; + ProtobufTypes::MessagePtr proto_config = factory.createEmptyRouteConfigProto(); + TestUtility::loadFromYaml(per_route_config_yaml, *proto_config); + + testing::NiceMock context; + EXPECT_CALL(context, messageValidationVisitor()); + auto route_config = factory.createRouteSpecificFilterConfig(*proto_config, context, + context.messageValidationVisitor()); + EXPECT_TRUE(route_config.ok()); + + const auto& typed_config = dynamic_cast(*route_config.value()); + EXPECT_TRUE(typed_config.httpService().has_value()); + EXPECT_FALSE(typed_config.grpcService().has_value()); + + const auto& http_service = typed_config.httpService().value(); + EXPECT_EQ(http_service.server_uri().uri(), "https://ext-authz-custom.example.com"); + EXPECT_EQ(http_service.server_uri().cluster(), "ext_authz_custom_cluster"); + EXPECT_EQ(http_service.server_uri().timeout().seconds(), 10); + EXPECT_EQ(http_service.path_prefix(), "/custom/auth"); +} + +TEST_F(ExtAuthzFilterHttpTest, PerRouteGrpcServiceWithTimeout) { + // Test gRPC service configuration with custom timeout + const std::string per_route_config_yaml = R"EOF( + check_settings: + grpc_service: + envoy_grpc: + cluster_name: "ext_authz_custom_grpc" + authority: "custom-ext-authz.example.com" + timeout: 15s + )EOF"; + + ExtAuthzFilterConfig factory; + ProtobufTypes::MessagePtr proto_config = factory.createEmptyRouteConfigProto(); + TestUtility::loadFromYaml(per_route_config_yaml, *proto_config); + + testing::NiceMock context; + EXPECT_CALL(context, messageValidationVisitor()); + auto route_config = factory.createRouteSpecificFilterConfig(*proto_config, context, + context.messageValidationVisitor()); + EXPECT_TRUE(route_config.ok()); + + const auto& typed_config = dynamic_cast(*route_config.value()); + EXPECT_TRUE(typed_config.grpcService().has_value()); + EXPECT_FALSE(typed_config.httpService().has_value()); + + const auto& grpc_service = typed_config.grpcService().value(); + EXPECT_TRUE(grpc_service.has_envoy_grpc()); + EXPECT_EQ(grpc_service.envoy_grpc().cluster_name(), "ext_authz_custom_grpc"); + EXPECT_EQ(grpc_service.envoy_grpc().authority(), "custom-ext-authz.example.com"); + EXPECT_EQ(grpc_service.timeout().seconds(), 15); +} + +TEST_F(ExtAuthzFilterHttpTest, PerRouteEmptyContextExtensionsMerging) { + // Test merging when one config has empty context extensions + const std::string less_specific_config_yaml = R"EOF( + check_settings: + context_extensions: + base_key: "base_value" + shared_key: "base_shared" + grpc_service: + envoy_grpc: + cluster_name: "base_cluster" + )EOF"; + + const std::string more_specific_config_yaml = R"EOF( + check_settings: + grpc_service: + envoy_grpc: + cluster_name: "specific_cluster" + )EOF"; + + ExtAuthzFilterConfig factory; + + ProtobufTypes::MessagePtr less_specific_proto = factory.createEmptyRouteConfigProto(); + TestUtility::loadFromYaml(less_specific_config_yaml, *less_specific_proto); + FilterConfigPerRoute less_specific_config( + *dynamic_cast( + less_specific_proto.get())); + + ProtobufTypes::MessagePtr more_specific_proto = factory.createEmptyRouteConfigProto(); + TestUtility::loadFromYaml(more_specific_config_yaml, *more_specific_proto); + FilterConfigPerRoute more_specific_config( + *dynamic_cast( + more_specific_proto.get())); + + FilterConfigPerRoute merged_config(less_specific_config, more_specific_config); + + // Should use gRPC service from more specific + EXPECT_TRUE(merged_config.grpcService().has_value()); + EXPECT_EQ(merged_config.grpcService().value().envoy_grpc().cluster_name(), "specific_cluster"); + + // Should preserve context extensions from less specific since more specific has none + const auto& context_extensions = merged_config.contextExtensions(); + EXPECT_EQ(context_extensions.size(), 2); + EXPECT_EQ(context_extensions.at("base_key"), "base_value"); + EXPECT_EQ(context_extensions.at("shared_key"), "base_shared"); +} + +TEST_F(ExtAuthzFilterHttpTest, PerRouteGrpcServiceConfigurationMerging) { + // Test merging of per-route configurations + const std::string less_specific_config_yaml = R"EOF( + check_settings: + context_extensions: + virtual_host: "my_virtual_host" + shared_setting: "from_less_specific" + grpc_service: + envoy_grpc: + cluster_name: "ext_authz_default" + )EOF"; + + const std::string more_specific_config_yaml = R"EOF( + check_settings: + context_extensions: + route_type: "high_qps" + shared_setting: "from_more_specific" + grpc_service: + envoy_grpc: + cluster_name: "ext_authz_high_qps" + )EOF"; + + ExtAuthzFilterConfig factory; + + // Create less specific configuration + ProtobufTypes::MessagePtr less_specific_proto = factory.createEmptyRouteConfigProto(); + TestUtility::loadFromYaml(less_specific_config_yaml, *less_specific_proto); + FilterConfigPerRoute less_specific_config( + *dynamic_cast( + less_specific_proto.get())); + + // Create more specific configuration + ProtobufTypes::MessagePtr more_specific_proto = factory.createEmptyRouteConfigProto(); + TestUtility::loadFromYaml(more_specific_config_yaml, *more_specific_proto); + FilterConfigPerRoute more_specific_config( + *dynamic_cast( + more_specific_proto.get())); + + // Merge configurations + FilterConfigPerRoute merged_config(less_specific_config, more_specific_config); + + // Check that more specific gRPC service is used + EXPECT_TRUE(merged_config.grpcService().has_value()); + const auto& grpc_service = merged_config.grpcService().value(); + EXPECT_TRUE(grpc_service.has_envoy_grpc()); + EXPECT_EQ(grpc_service.envoy_grpc().cluster_name(), "ext_authz_high_qps"); + + // Check that context extensions are properly merged + const auto& context_extensions = merged_config.contextExtensions(); + EXPECT_EQ(context_extensions.at("virtual_host"), "my_virtual_host"); + EXPECT_EQ(context_extensions.at("route_type"), "high_qps"); + EXPECT_EQ(context_extensions.at("shared_setting"), "from_more_specific"); +} + +TEST_F(ExtAuthzFilterHttpTest, PerRouteGrpcServiceConfigurationWithoutGrpcService) { + const std::string per_route_config_yaml = R"EOF( + check_settings: + context_extensions: + virtual_host: "my_virtual_host" + disable_request_body_buffering: true + )EOF"; + + ExtAuthzFilterConfig factory; + ProtobufTypes::MessagePtr proto_config = factory.createEmptyRouteConfigProto(); + TestUtility::loadFromYaml(per_route_config_yaml, *proto_config); + + testing::NiceMock context; + EXPECT_CALL(context, messageValidationVisitor()); + auto route_config = factory.createRouteSpecificFilterConfig(*proto_config, context, + context.messageValidationVisitor()); + EXPECT_TRUE(route_config.ok()); + + const auto& typed_config = dynamic_cast(*route_config.value()); + EXPECT_FALSE(typed_config.disabled()); + EXPECT_FALSE(typed_config.grpcService().has_value()); + + const auto& context_extensions = typed_config.contextExtensions(); + EXPECT_EQ(context_extensions.at("virtual_host"), "my_virtual_host"); +} + +TEST_F(ExtAuthzFilterHttpTest, PerRouteGrpcServiceConfigurationDisabled) { + const std::string per_route_config_yaml = R"EOF( + disabled: true + )EOF"; + + ExtAuthzFilterConfig factory; + ProtobufTypes::MessagePtr proto_config = factory.createEmptyRouteConfigProto(); + TestUtility::loadFromYaml(per_route_config_yaml, *proto_config); + + testing::NiceMock context; + EXPECT_CALL(context, messageValidationVisitor()); + auto route_config = factory.createRouteSpecificFilterConfig(*proto_config, context, + context.messageValidationVisitor()); + EXPECT_TRUE(route_config.ok()); + + const auto& typed_config = dynamic_cast(*route_config.value()); + EXPECT_TRUE(typed_config.disabled()); + EXPECT_FALSE(typed_config.grpcService().has_value()); +} + class ExtAuthzFilterGrpcTest : public ExtAuthzFilterTest { public: void testFilterFactoryAndFilterWithGrpcClient(const std::string& ext_authz_config_yaml) { diff --git a/test/extensions/filters/http/ext_authz/ext_authz_integration_test.cc b/test/extensions/filters/http/ext_authz/ext_authz_integration_test.cc index e072262e484b5..9eb2348bdbafd 100644 --- a/test/extensions/filters/http/ext_authz/ext_authz_integration_test.cc +++ b/test/extensions/filters/http/ext_authz/ext_authz_integration_test.cc @@ -5,6 +5,7 @@ #include "source/common/common/macros.h" #include "source/extensions/filters/common/ext_authz/ext_authz.h" +#include "source/extensions/filters/http/ext_authz/ext_authz.h" #include "source/server/config_validation/server.h" #include "test/common/grpc/grpc_client_integration.h" @@ -983,6 +984,32 @@ INSTANTIATE_TEST_SUITE_P(IpVersionsCientType, ExtAuthzGrpcIntegrationTest, testing::Bool()), ExtAuthzGrpcIntegrationTest::testParamsToString); +// Test per-route gRPC service configuration parsing +TEST_P(ExtAuthzGrpcIntegrationTest, PerRouteGrpcServiceConfigurationParsing) { + // Create a simple per-route configuration with gRPC service + envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute per_route_config; + per_route_config.mutable_check_settings() + ->mutable_grpc_service() + ->mutable_envoy_grpc() + ->set_cluster_name("per_route_cluster"); + (*per_route_config.mutable_check_settings()->mutable_context_extensions())["route_type"] = + "special"; + + // Test configuration parsing and validation + Envoy::Extensions::HttpFilters::ExtAuthz::FilterConfigPerRoute config_per_route(per_route_config); + + // Verify the configuration was parsed correctly + ASSERT_TRUE(config_per_route.grpcService().has_value()); + EXPECT_TRUE(config_per_route.grpcService().value().has_envoy_grpc()); + EXPECT_EQ(config_per_route.grpcService().value().envoy_grpc().cluster_name(), + "per_route_cluster"); + + // Verify context extensions are present + const auto& check_settings = config_per_route.checkSettings(); + ASSERT_TRUE(check_settings.context_extensions().contains("route_type")); + EXPECT_EQ(check_settings.context_extensions().at("route_type"), "special"); +} + // Verifies that the request body is included in the CheckRequest when the downstream protocol is // HTTP/1.1. TEST_P(ExtAuthzGrpcIntegrationTest, HTTP1DownstreamRequestWithBody) { diff --git a/test/extensions/filters/http/ext_authz/ext_authz_test.cc b/test/extensions/filters/http/ext_authz/ext_authz_test.cc index 129798b8cefb5..9d5fa891fb18c 100644 --- a/test/extensions/filters/http/ext_authz/ext_authz_test.cc +++ b/test/extensions/filters/http/ext_authz/ext_authz_test.cc @@ -71,7 +71,8 @@ template class HttpFilterTestBase : public T { config_ = std::make_shared(proto_config, *stats_store_.rootScope(), "ext_authz_prefix", factory_context_); client_ = new NiceMock(); - filter_ = std::make_unique(config_, Filters::Common::ExtAuthz::ClientPtr{client_}); + filter_ = std::make_unique(config_, Filters::Common::ExtAuthz::ClientPtr{client_}, + factory_context_); ON_CALL(decoder_filter_callbacks_, filterConfigName()).WillByDefault(Return(FilterConfigName)); filter_->setDecoderFilterCallbacks(decoder_filter_callbacks_); filter_->setEncoderFilterCallbacks(encoder_filter_callbacks_); @@ -439,9 +440,13 @@ class InvalidMutationTest : public HttpFilterTestBase { EXPECT_EQ(1U, config_->stats().invalid_.value()); } - const std::string invalid_key_ = "invalid-\nkey"; - const uint8_t invalid_value_bytes_[3]{0x7f, 0x7f, 0}; + static constexpr const char* invalid_key_ = "invalid-\nkey"; + static constexpr uint8_t invalid_value_bytes_[3]{0x7f, 0x7f, 0}; const std::string invalid_value_; + + static std::string getInvalidValue() { + return std::string(reinterpret_cast(invalid_value_bytes_)); + } }; TEST_F(HttpFilterTest, DisableDynamicMetadataIngestion) { @@ -484,139 +489,149 @@ TEST_F(HttpFilterTest, DisableDynamicMetadataIngestion) { // Tests that the filter rejects authz responses with mutations with an invalid key when // validate_authz_response is set to true in config. -TEST_F(InvalidMutationTest, HeadersToSetKey) { - Filters::Common::ExtAuthz::Response response; - response.status = Filters::Common::ExtAuthz::CheckStatus::OK; - response.headers_to_set = {{invalid_key_, "bar"}}; - testResponse(response); -} - -// Same as above, setting a different field... -TEST_F(InvalidMutationTest, HeadersToAddKey) { - Filters::Common::ExtAuthz::Response response; - response.status = Filters::Common::ExtAuthz::CheckStatus::OK; - response.headers_to_add = {{invalid_key_, "bar"}}; - testResponse(response); -} - -// headers_to_set is also used when the authz response has status denied. -TEST_F(InvalidMutationTest, HeadersToSetKeyDenied) { - Filters::Common::ExtAuthz::Response response; - response.status = Filters::Common::ExtAuthz::CheckStatus::Denied; - response.headers_to_set = {{invalid_key_, "bar"}}; - testResponse(response); -} - -TEST_F(InvalidMutationTest, HeadersToAppendKey) { - Filters::Common::ExtAuthz::Response response; - response.status = Filters::Common::ExtAuthz::CheckStatus::OK; - response.headers_to_append = {{invalid_key_, "bar"}}; - testResponse(response); -} - -TEST_F(InvalidMutationTest, ResponseHeadersToAddKey) { - Filters::Common::ExtAuthz::Response response; - response.status = Filters::Common::ExtAuthz::CheckStatus::OK; - response.response_headers_to_set = {{invalid_key_, "bar"}}; - testResponse(response); -} - -TEST_F(InvalidMutationTest, ResponseHeadersToSetKey) { - Filters::Common::ExtAuthz::Response response; - response.status = Filters::Common::ExtAuthz::CheckStatus::OK; - response.response_headers_to_set = {{invalid_key_, "bar"}}; - testResponse(response); -} - -TEST_F(InvalidMutationTest, ResponseHeadersToAddIfAbsentKey) { - Filters::Common::ExtAuthz::Response response; - response.status = Filters::Common::ExtAuthz::CheckStatus::OK; - response.response_headers_to_add_if_absent = {{invalid_key_, "bar"}}; - testResponse(response); -} - -TEST_F(InvalidMutationTest, ResponseHeadersToOverwriteIfExistsKey) { - Filters::Common::ExtAuthz::Response response; - response.status = Filters::Common::ExtAuthz::CheckStatus::OK; - response.response_headers_to_overwrite_if_exists = {{invalid_key_, "bar"}}; - testResponse(response); -} - -TEST_F(InvalidMutationTest, QueryParametersToSetKey) { - Filters::Common::ExtAuthz::Response response; - response.status = Filters::Common::ExtAuthz::CheckStatus::OK; - response.query_parameters_to_set = {{"f o o", "bar"}}; - testResponse(response); -} - -// Test that the filter rejects mutations with an invalid value -TEST_F(InvalidMutationTest, HeadersToSetValueOk) { - Filters::Common::ExtAuthz::Response response; - response.status = Filters::Common::ExtAuthz::CheckStatus::OK; - response.headers_to_set = {{"foo", invalid_value_}}; - testResponse(response); -} - -// Same as above, setting a different field... -TEST_F(InvalidMutationTest, HeadersToAddValue) { - Filters::Common::ExtAuthz::Response response; - response.status = Filters::Common::ExtAuthz::CheckStatus::OK; - response.headers_to_add = {{"foo", invalid_value_}}; - testResponse(response); -} - -// headers_to_set is also used when the authz response has status denied. -TEST_F(InvalidMutationTest, HeadersToSetValueDenied) { - Filters::Common::ExtAuthz::Response response; - response.status = Filters::Common::ExtAuthz::CheckStatus::Denied; - response.headers_to_set = {{"foo", invalid_value_}}; - testResponse(response); -} - -TEST_F(InvalidMutationTest, HeadersToAppendValue) { - Filters::Common::ExtAuthz::Response response; - response.status = Filters::Common::ExtAuthz::CheckStatus::OK; - response.headers_to_append = {{"foo", invalid_value_}}; - testResponse(response); -} +// Parameterized test for invalid mutation scenarios to reduce redundancy. +class InvalidMutationParamTest + : public InvalidMutationTest, + public testing::WithParamInterface< + std::tuple, // setup func + Filters::Common::ExtAuthz::CheckStatus // status + >> {}; + +TEST_P(InvalidMutationParamTest, InvalidMutationFields) { + const auto& [test_name, setup_func, status] = GetParam(); -TEST_F(InvalidMutationTest, ResponseHeadersToAddValue) { Filters::Common::ExtAuthz::Response response; - response.status = Filters::Common::ExtAuthz::CheckStatus::OK; - // Add a valid header to see if it gets added to the downstream response. - response.response_headers_to_set = {{"foo", invalid_value_}}; + response.status = status; + setup_func(response); testResponse(response); } -TEST_F(InvalidMutationTest, ResponseHeadersToSetValue) { - Filters::Common::ExtAuthz::Response response; - response.status = Filters::Common::ExtAuthz::CheckStatus::OK; - // Add a valid header to see if it gets added to the downstream response. - response.response_headers_to_set = {{"foo", invalid_value_}}; - testResponse(response); -} - -TEST_F(InvalidMutationTest, ResponseHeadersToAddIfAbsentValue) { - Filters::Common::ExtAuthz::Response response; - response.status = Filters::Common::ExtAuthz::CheckStatus::OK; - // Add a valid header to see if it gets added to the downstream response. - response.response_headers_to_add_if_absent = {{"foo", invalid_value_}}; - testResponse(response); -} - -TEST_F(InvalidMutationTest, ResponseHeadersToOverwriteIfExistsValue) { - Filters::Common::ExtAuthz::Response response; - response.status = Filters::Common::ExtAuthz::CheckStatus::OK; - // Add a valid header to see if it gets added to the downstream response. - response.response_headers_to_overwrite_if_exists = {{"foo", invalid_value_}}; - testResponse(response); -} - -TEST_F(InvalidMutationTest, QueryParametersToSetValue) { +INSTANTIATE_TEST_SUITE_P( + InvalidMutationScenarios, InvalidMutationParamTest, + testing::Values( + // Invalid key tests + std::make_tuple( + "HeadersToSetKey", + [](Filters::Common::ExtAuthz::Response& r) { + r.headers_to_set = {{InvalidMutationTest::invalid_key_, "bar"}}; + }, + Filters::Common::ExtAuthz::CheckStatus::OK), + std::make_tuple( + "HeadersToAddKey", + [](Filters::Common::ExtAuthz::Response& r) { + r.headers_to_add = {{InvalidMutationTest::invalid_key_, "bar"}}; + }, + Filters::Common::ExtAuthz::CheckStatus::OK), + std::make_tuple( + "HeadersToSetKeyDenied", + [](Filters::Common::ExtAuthz::Response& r) { + r.headers_to_set = {{InvalidMutationTest::invalid_key_, "bar"}}; + }, + Filters::Common::ExtAuthz::CheckStatus::Denied), + std::make_tuple( + "HeadersToAppendKey", + [](Filters::Common::ExtAuthz::Response& r) { + r.headers_to_append = {{InvalidMutationTest::invalid_key_, "bar"}}; + }, + Filters::Common::ExtAuthz::CheckStatus::OK), + std::make_tuple( + "ResponseHeadersToAddKey", + [](Filters::Common::ExtAuthz::Response& r) { + r.response_headers_to_set = {{InvalidMutationTest::invalid_key_, "bar"}}; + }, + Filters::Common::ExtAuthz::CheckStatus::OK), + std::make_tuple( + "ResponseHeadersToSetKey", + [](Filters::Common::ExtAuthz::Response& r) { + r.response_headers_to_set = {{InvalidMutationTest::invalid_key_, "bar"}}; + }, + Filters::Common::ExtAuthz::CheckStatus::OK), + std::make_tuple( + "ResponseHeadersToAddIfAbsentKey", + [](Filters::Common::ExtAuthz::Response& r) { + r.response_headers_to_add_if_absent = {{InvalidMutationTest::invalid_key_, "bar"}}; + }, + Filters::Common::ExtAuthz::CheckStatus::OK), + std::make_tuple( + "ResponseHeadersToOverwriteIfExistsKey", + [](Filters::Common::ExtAuthz::Response& r) { + r.response_headers_to_overwrite_if_exists = { + {InvalidMutationTest::invalid_key_, "bar"}}; + }, + Filters::Common::ExtAuthz::CheckStatus::OK), + std::make_tuple( + "QueryParametersToSetKey", + [](Filters::Common::ExtAuthz::Response& r) { + r.query_parameters_to_set = {{"f o o", "bar"}}; + }, + Filters::Common::ExtAuthz::CheckStatus::OK), + // Invalid value tests + std::make_tuple( + "HeadersToSetValue", + [](Filters::Common::ExtAuthz::Response& r) { + r.headers_to_set = {{"foo", InvalidMutationTest::getInvalidValue()}}; + }, + Filters::Common::ExtAuthz::CheckStatus::OK), + std::make_tuple( + "HeadersToAddValue", + [](Filters::Common::ExtAuthz::Response& r) { + r.headers_to_add = {{"foo", InvalidMutationTest::getInvalidValue()}}; + }, + Filters::Common::ExtAuthz::CheckStatus::OK), + std::make_tuple( + "HeadersToSetValueDenied", + [](Filters::Common::ExtAuthz::Response& r) { + r.headers_to_set = {{"foo", InvalidMutationTest::getInvalidValue()}}; + }, + Filters::Common::ExtAuthz::CheckStatus::Denied), + std::make_tuple( + "HeadersToAppendValue", + [](Filters::Common::ExtAuthz::Response& r) { + r.headers_to_append = {{"foo", InvalidMutationTest::getInvalidValue()}}; + }, + Filters::Common::ExtAuthz::CheckStatus::OK), + std::make_tuple( + "ResponseHeadersToAddValue", + [](Filters::Common::ExtAuthz::Response& r) { + r.response_headers_to_set = {{"foo", InvalidMutationTest::getInvalidValue()}}; + }, + Filters::Common::ExtAuthz::CheckStatus::OK), + std::make_tuple( + "ResponseHeadersToSetValue", + [](Filters::Common::ExtAuthz::Response& r) { + r.response_headers_to_set = {{"foo", InvalidMutationTest::getInvalidValue()}}; + }, + Filters::Common::ExtAuthz::CheckStatus::OK), + std::make_tuple( + "ResponseHeadersToAddIfAbsentValue", + [](Filters::Common::ExtAuthz::Response& r) { + r.response_headers_to_add_if_absent = { + {"foo", InvalidMutationTest::getInvalidValue()}}; + }, + Filters::Common::ExtAuthz::CheckStatus::OK), + std::make_tuple( + "ResponseHeadersToOverwriteIfExistsValue", + [](Filters::Common::ExtAuthz::Response& r) { + r.response_headers_to_overwrite_if_exists = { + {"foo", InvalidMutationTest::getInvalidValue()}}; + }, + Filters::Common::ExtAuthz::CheckStatus::OK), + std::make_tuple( + "QueryParametersToSetValue", + [](Filters::Common::ExtAuthz::Response& r) { + r.query_parameters_to_set = {{"foo", "b a r"}}; + }, + Filters::Common::ExtAuthz::CheckStatus::OK)), + [](const testing::TestParamInfo& info) { + return std::get<0>(info.param); + }); + +// Keep one simple focused test to ensure backward compatibility. +TEST_F(InvalidMutationTest, BasicInvalidKey) { Filters::Common::ExtAuthz::Response response; response.status = Filters::Common::ExtAuthz::CheckStatus::OK; - // Add a valid header to see if it gets added to the downstream response. - response.query_parameters_to_set = {{"foo", "b a r"}}; + response.headers_to_set = {{invalid_key_, "bar"}}; testResponse(response); } @@ -824,68 +839,55 @@ TEST_F(DecoderHeaderMutationRulesTest, DisallowAll) { runTest(opts); } -TEST_F(DecoderHeaderMutationRulesTest, RejectResponseAdd) { - DecoderHeaderMutationRulesTestOpts opts; - opts.rules = envoy::config::common::mutation_rules::v3::HeaderMutationRules(); - opts.rules->mutable_disallow_all()->set_value(true); - opts.rules->mutable_disallow_is_error()->set_value(true); - opts.expect_reject_response = true; - - opts.disallowed_headers_to_add = {{"cant-add-me", "sad"}}; - runTest(opts); -} - -TEST_F(DecoderHeaderMutationRulesTest, RejectResponseAppend) { - DecoderHeaderMutationRulesTestOpts opts; - opts.rules = envoy::config::common::mutation_rules::v3::HeaderMutationRules(); - opts.rules->mutable_disallow_all()->set_value(true); - opts.rules->mutable_disallow_is_error()->set_value(true); - opts.expect_reject_response = true; - - opts.disallowed_headers_to_append = {{"cant-append-to-me", "fail"}}; - runTest(opts); -} - -TEST_F(DecoderHeaderMutationRulesTest, RejectResponseAppendPseudoheader) { - DecoderHeaderMutationRulesTestOpts opts; - opts.rules = envoy::config::common::mutation_rules::v3::HeaderMutationRules(); - opts.rules->mutable_disallow_is_error()->set_value(true); - opts.expect_reject_response = true; - - opts.disallowed_headers_to_append = {{":fake-pseudo-header", "fail"}}; - runTest(opts); -} - -TEST_F(DecoderHeaderMutationRulesTest, RejectResponseSet) { - DecoderHeaderMutationRulesTestOpts opts; - opts.rules = envoy::config::common::mutation_rules::v3::HeaderMutationRules(); - opts.rules->mutable_disallow_all()->set_value(true); - opts.rules->mutable_disallow_is_error()->set_value(true); - opts.expect_reject_response = true; - - opts.disallowed_headers_to_set = {{"cant-override-me", "nope"}}; - runTest(opts); -} - -TEST_F(DecoderHeaderMutationRulesTest, RejectResponseRemove) { - DecoderHeaderMutationRulesTestOpts opts; - opts.rules = envoy::config::common::mutation_rules::v3::HeaderMutationRules(); - opts.rules->mutable_disallow_all()->set_value(true); - opts.rules->mutable_disallow_is_error()->set_value(true); - opts.expect_reject_response = true; - - opts.disallowed_headers_to_remove = {"cant-delete-me"}; - runTest(opts); -} +// Consolidated rejection test that covers all the scenarios previously tested individually. +TEST_F(DecoderHeaderMutationRulesTest, RejectResponseOperations) { + // Test data structure for all rejection scenarios + struct TestCase { + std::string name; + bool use_disallow_all; + std::function setup_func; + }; -TEST_F(DecoderHeaderMutationRulesTest, RejectResponseRemovePseudoHeader) { - DecoderHeaderMutationRulesTestOpts opts; - opts.rules = envoy::config::common::mutation_rules::v3::HeaderMutationRules(); - opts.rules->mutable_disallow_is_error()->set_value(true); - opts.expect_reject_response = true; + std::vector test_cases = { + {"RejectResponseAdd", true, + [](DecoderHeaderMutationRulesTestOpts& opts) { + opts.disallowed_headers_to_add = {{"cant-add-me", "sad"}}; + }}, + {"RejectResponseAppend", true, + [](DecoderHeaderMutationRulesTestOpts& opts) { + opts.disallowed_headers_to_append = {{"cant-append-to-me", "fail"}}; + }}, + {"RejectResponseAppendPseudoheader", false, + [](DecoderHeaderMutationRulesTestOpts& opts) { + opts.disallowed_headers_to_append = {{":fake-pseudo-header", "fail"}}; + }}, + {"RejectResponseSet", true, + [](DecoderHeaderMutationRulesTestOpts& opts) { + opts.disallowed_headers_to_set = {{"cant-override-me", "nope"}}; + }}, + {"RejectResponseRemove", true, + [](DecoderHeaderMutationRulesTestOpts& opts) { + opts.disallowed_headers_to_remove = {"cant-delete-me"}; + }}, + {"RejectResponseRemovePseudoHeader", false, [](DecoderHeaderMutationRulesTestOpts& opts) { + opts.disallowed_headers_to_remove = {":fake-pseudo-header"}; + }}}; + + // Run all test cases + for (const auto& test_case : test_cases) { + SCOPED_TRACE(test_case.name); + + DecoderHeaderMutationRulesTestOpts opts; + opts.rules = envoy::config::common::mutation_rules::v3::HeaderMutationRules(); + if (test_case.use_disallow_all) { + opts.rules->mutable_disallow_all()->set_value(true); + } + opts.rules->mutable_disallow_is_error()->set_value(true); + opts.expect_reject_response = true; - opts.disallowed_headers_to_remove = {":fake-pseudo-header"}; - runTest(opts); + test_case.setup_func(opts); + runTest(opts); + } } TEST_F(DecoderHeaderMutationRulesTest, DisallowExpression) { @@ -2828,19 +2830,20 @@ TEST_P(HttpFilterTestParam, ContextExtensions) { // Test that filter can be disabled with route config. TEST_P(HttpFilterTestParam, DisabledOnRoute) { envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute settings; - FilterConfigPerRoute auth_per_route(settings); + std::unique_ptr auth_per_route = + std::make_unique(settings); prepareCheck(); - ON_CALL(*decoder_filter_callbacks_.route_, mostSpecificPerFilterConfig(_)) - .WillByDefault(Return(&auth_per_route)); - auto test_disable = [&](bool disabled) { initialize(""); // Set disabled settings.set_disabled(disabled); // Initialize the route's per filter config. - auth_per_route = FilterConfigPerRoute(settings); + auth_per_route = std::make_unique(settings); + // Update the mock to return the new pointer + ON_CALL(*decoder_filter_callbacks_.route_, mostSpecificPerFilterConfig(_)) + .WillByDefault(Return(auth_per_route.get())); }; // baseline: make sure that when not disabled, check is called @@ -2861,10 +2864,8 @@ TEST_P(HttpFilterTestParam, DisabledOnRoute) { // Test that filter can be disabled with route config. TEST_P(HttpFilterTestParam, DisabledOnRouteWithRequestBody) { envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute settings; - FilterConfigPerRoute auth_per_route(settings); - - ON_CALL(*decoder_filter_callbacks_.route_, mostSpecificPerFilterConfig(_)) - .WillByDefault(Return(&auth_per_route)); + std::unique_ptr auth_per_route = + std::make_unique(settings); auto test_disable = [&](bool disabled) { initialize(R"EOF( @@ -2880,7 +2881,10 @@ TEST_P(HttpFilterTestParam, DisabledOnRouteWithRequestBody) { // Set the filter disabled setting. settings.set_disabled(disabled); // Initialize the route's per filter config. - auth_per_route = FilterConfigPerRoute(settings); + auth_per_route = std::make_unique(settings); + // Update the mock to return the new pointer. + ON_CALL(*decoder_filter_callbacks_.route_, mostSpecificPerFilterConfig(_)) + .WillByDefault(Return(auth_per_route.get())); }; test_disable(false); @@ -3914,6 +3918,31 @@ TEST_F(HttpFilterTest, PerRouteCheckSettingsWorks) { } // Checks that the per-route filter can override the check_settings set on the main filter. +TEST_F(HttpFilterTest, NullRouteSkipsCheck) { + initialize(R"EOF( + grpc_service: + envoy_grpc: + cluster_name: "ext_authz_server" + failure_mode_allow: false + stat_prefix: "ext_authz" + )EOF"); + + prepareCheck(); + + // Set up a null route return value. + ON_CALL(decoder_filter_callbacks_, route()).WillByDefault(Return(nullptr)); + + // With null route, no authorization check should be performed. + EXPECT_CALL(*client_, check(_, _, _, _)).Times(0); + + // Call the filter directly. + Http::TestRequestHeaderMapImpl request_headers{ + {":method", "GET"}, {":path", "/test"}, {":scheme", "http"}, {"host", "example.com"}}; + + // With null route, the filter should continue without an auth check. + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, false)); +} + TEST_F(HttpFilterTest, PerRouteCheckSettingsOverrideWorks) { InSequence s; @@ -3975,10 +4004,8 @@ TEST_F(HttpFilterTest, PerRouteCheckSettingsOverrideWorks) { // Verify that request body buffering can be skipped per route. TEST_P(HttpFilterTestParam, DisableRequestBodyBufferingOnRoute) { envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute settings; - FilterConfigPerRoute auth_per_route(settings); - - ON_CALL(*decoder_filter_callbacks_.route_, mostSpecificPerFilterConfig(_)) - .WillByDefault(Return(&auth_per_route)); + std::unique_ptr auth_per_route = + std::make_unique(settings); auto test_disable_request_body_buffering = [&](bool bypass) { initialize(R"EOF( @@ -3994,7 +4021,10 @@ TEST_P(HttpFilterTestParam, DisableRequestBodyBufferingOnRoute) { // Set bypass request body buffering for this route. settings.mutable_check_settings()->set_disable_request_body_buffering(bypass); // Initialize the route's per filter config. - auth_per_route = FilterConfigPerRoute(settings); + auth_per_route = std::make_unique(settings); + // Update the mock to return the new pointer. + ON_CALL(*decoder_filter_callbacks_.route_, mostSpecificPerFilterConfig(_)) + .WillByDefault(Return(auth_per_route.get())); }; test_disable_request_body_buffering(false); @@ -4159,6 +4189,801 @@ TEST_P(EmitFilterStateTest, PreexistingFilterStateSameTypeMutable) { TEST_P(ExtAuthzLoggingInfoTest, FieldTest) { test(); } +// Test per-route gRPC service override with null server context (fallback to default client) +TEST_P(HttpFilterTestParam, PerRouteGrpcServiceOverrideWithNullServerContext) { + if (std::get<1>(GetParam())) { + // Skip HTTP client test - per-route gRPC service only applies to gRPC clients + return; + } + + envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute per_route_config; + per_route_config.mutable_check_settings() + ->mutable_grpc_service() + ->mutable_envoy_grpc() + ->set_cluster_name("per_route_ext_authz_cluster"); + + std::unique_ptr per_route_filter_config = + std::make_unique(per_route_config); + + // Set up route to return per-route config + ON_CALL(*decoder_filter_callbacks_.route_, mostSpecificPerFilterConfig(_)) + .WillByDefault(Return(per_route_filter_config.get())); + + prepareCheck(); + + // Mock the default client check call (should fall back to default since server context is null) + EXPECT_CALL(*client_, check(_, _, _, _)) + .WillOnce(Invoke([&](Filters::Common::ExtAuthz::RequestCallbacks& callbacks, + const envoy::service::auth::v3::CheckRequest&, Tracing::Span&, + const StreamInfo::StreamInfo&) -> void { + Filters::Common::ExtAuthz::Response response{}; + response.status = Filters::Common::ExtAuthz::CheckStatus::OK; + callbacks.onComplete(std::make_unique(response)); + })); + + EXPECT_CALL(decoder_filter_callbacks_, continueDecoding()).Times(0); + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers_, false)); +} + +// Test per-route configuration merging with context extensions +TEST_P(HttpFilterTestParam, PerRouteConfigurationMergingWithContextExtensions) { + if (std::get<1>(GetParam())) { + // Skip HTTP client test - configuration merging applies to gRPC clients + return; + } + + // Create base configuration with context extensions + envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute base_config; + base_config.mutable_check_settings()->mutable_context_extensions()->insert( + {"base_key", "base_value"}); + base_config.mutable_check_settings()->mutable_context_extensions()->insert( + {"shared_key", "base_shared_value"}); + + // Create more specific configuration with context extensions + envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute specific_config; + specific_config.mutable_check_settings()->mutable_context_extensions()->insert( + {"specific_key", "specific_value"}); + specific_config.mutable_check_settings()->mutable_context_extensions()->insert( + {"shared_key", "specific_shared_value"}); + + // Test merging using the merge constructor + FilterConfigPerRoute base_filter_config(base_config); + FilterConfigPerRoute specific_filter_config(specific_config); + FilterConfigPerRoute merged_config(base_filter_config, specific_filter_config); + + // Verify merged context extensions + const auto& merged_extensions = merged_config.contextExtensions(); + EXPECT_EQ(merged_extensions.size(), 3); + EXPECT_EQ(merged_extensions.at("base_key"), "base_value"); + EXPECT_EQ(merged_extensions.at("specific_key"), "specific_value"); + EXPECT_EQ(merged_extensions.at("shared_key"), "specific_shared_value"); // More specific wins +} + +// Test per-route configuration merging with gRPC service override +TEST_P(HttpFilterTestParam, PerRouteConfigurationMergingWithGrpcServiceOverride) { + if (std::get<1>(GetParam())) { + // Skip HTTP client test - gRPC service override applies to gRPC clients + return; + } + + // Create base configuration without gRPC service + envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute base_config; + base_config.mutable_check_settings()->mutable_context_extensions()->insert( + {"base_key", "base_value"}); + + // Create more specific configuration with gRPC service + envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute specific_config; + specific_config.mutable_check_settings() + ->mutable_grpc_service() + ->mutable_envoy_grpc() + ->set_cluster_name("specific_cluster"); + specific_config.mutable_check_settings()->mutable_context_extensions()->insert( + {"specific_key", "specific_value"}); + + // Test merging using the merge constructor + FilterConfigPerRoute base_filter_config(base_config); + FilterConfigPerRoute specific_filter_config(specific_config); + FilterConfigPerRoute merged_config(base_filter_config, specific_filter_config); + + // Verify gRPC service override is from more specific config + EXPECT_TRUE(merged_config.grpcService().has_value()); + EXPECT_EQ(merged_config.grpcService().value().envoy_grpc().cluster_name(), "specific_cluster"); + + // Verify context extensions are merged + const auto& merged_extensions = merged_config.contextExtensions(); + EXPECT_EQ(merged_extensions.size(), 2); + EXPECT_EQ(merged_extensions.at("base_key"), "base_value"); + EXPECT_EQ(merged_extensions.at("specific_key"), "specific_value"); +} + +// Test per-route configuration merging with request body settings +TEST_P(HttpFilterTestParam, PerRouteConfigurationMergingWithRequestBodySettings) { + if (std::get<1>(GetParam())) { + // Skip HTTP client test - request body settings apply to gRPC clients + return; + } + + // Create base configuration with request body settings + envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute base_config; + base_config.mutable_check_settings()->mutable_with_request_body()->set_max_request_bytes(1000); + base_config.mutable_check_settings()->mutable_with_request_body()->set_allow_partial_message( + true); + + // Create more specific configuration with different request body settings + envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute specific_config; + specific_config.mutable_check_settings()->mutable_with_request_body()->set_max_request_bytes( + 2000); + specific_config.mutable_check_settings()->mutable_with_request_body()->set_allow_partial_message( + false); + + // Test merging using the merge constructor + FilterConfigPerRoute base_filter_config(base_config); + FilterConfigPerRoute specific_filter_config(specific_config); + FilterConfigPerRoute merged_config(base_filter_config, specific_filter_config); + + // Verify request body settings are from more specific config + const auto& merged_check_settings = merged_config.checkSettings(); + EXPECT_TRUE(merged_check_settings.has_with_request_body()); + EXPECT_EQ(merged_check_settings.with_request_body().max_request_bytes(), 2000); + EXPECT_EQ(merged_check_settings.with_request_body().allow_partial_message(), false); +} + +// Test per-route configuration merging with disable_request_body_buffering +TEST_P(HttpFilterTestParam, PerRouteConfigurationMergingWithDisableRequestBodyBuffering) { + if (std::get<1>(GetParam())) { + // Skip HTTP client test - disable request body buffering applies to gRPC clients + return; + } + + // Create base configuration without disable_request_body_buffering + envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute base_config; + base_config.mutable_check_settings()->mutable_context_extensions()->insert( + {"base_key", "base_value"}); + + // Create more specific configuration with disable_request_body_buffering + envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute specific_config; + specific_config.mutable_check_settings()->set_disable_request_body_buffering(true); + + // Test merging using the merge constructor + FilterConfigPerRoute base_filter_config(base_config); + FilterConfigPerRoute specific_filter_config(specific_config); + FilterConfigPerRoute merged_config(base_filter_config, specific_filter_config); + + // Verify disable_request_body_buffering is from more specific config + const auto& merged_check_settings = merged_config.checkSettings(); + EXPECT_TRUE(merged_check_settings.disable_request_body_buffering()); +} + +// Test per-route configuration merging with multiple levels +TEST_P(HttpFilterTestParam, PerRouteConfigurationMergingMultipleLevels) { + if (std::get<1>(GetParam())) { + // Skip HTTP client test - configuration merging applies to gRPC clients + return; + } + + // Create virtual host level configuration + envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute vh_config; + vh_config.mutable_check_settings()->mutable_context_extensions()->insert({"vh_key", "vh_value"}); + vh_config.mutable_check_settings()->mutable_context_extensions()->insert( + {"shared_key", "vh_shared_value"}); + + // Create route level configuration + envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute route_config; + route_config.mutable_check_settings()->mutable_context_extensions()->insert( + {"route_key", "route_value"}); + route_config.mutable_check_settings()->mutable_context_extensions()->insert( + {"shared_key", "route_shared_value"}); + route_config.mutable_check_settings() + ->mutable_grpc_service() + ->mutable_envoy_grpc() + ->set_cluster_name("route_cluster"); + + // Create weighted cluster level configuration + envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute wc_config; + wc_config.mutable_check_settings()->mutable_context_extensions()->insert({"wc_key", "wc_value"}); + wc_config.mutable_check_settings()->mutable_context_extensions()->insert( + {"shared_key", "wc_shared_value"}); + + // Test merging from least specific to most specific + FilterConfigPerRoute vh_filter_config(vh_config); + FilterConfigPerRoute route_filter_config(route_config); + FilterConfigPerRoute wc_filter_config(wc_config); + + // First merge: vh + route + FilterConfigPerRoute vh_route_merged(vh_filter_config, route_filter_config); + + // Second merge: (vh + route) + weighted cluster + FilterConfigPerRoute final_merged(vh_route_merged, wc_filter_config); + + // Verify final merged context extensions + const auto& merged_extensions = final_merged.contextExtensions(); + EXPECT_EQ(merged_extensions.size(), 4); + EXPECT_EQ(merged_extensions.at("vh_key"), "vh_value"); + EXPECT_EQ(merged_extensions.at("route_key"), "route_value"); + EXPECT_EQ(merged_extensions.at("wc_key"), "wc_value"); + EXPECT_EQ(merged_extensions.at("shared_key"), "wc_shared_value"); // Most specific wins + + // Verify gRPC service override is NOT inherited from less specific levels. + EXPECT_FALSE(final_merged.grpcService().has_value()); +} + +// Test per-route context extensions take precedence over check_settings context extensions. +TEST_P(HttpFilterTestParam, PerRouteContextExtensionsPrecedence) { + if (std::get<1>(GetParam())) { + // Skip HTTP client test as context extensions apply to gRPC clients. + return; + } + + // Create configuration with context extensions in both places. + envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute base_config; + base_config.mutable_check_settings()->mutable_context_extensions()->insert( + {"check_key", "check_value"}); + base_config.mutable_check_settings()->mutable_context_extensions()->insert( + {"shared_key", "check_shared_value"}); + + envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute specific_config; + specific_config.mutable_check_settings()->mutable_context_extensions()->insert( + {"specific_check_key", "specific_check_value"}); + specific_config.mutable_check_settings()->mutable_context_extensions()->insert( + {"shared_key", "specific_check_shared_value"}); + + // Test merging using the merge constructor. + FilterConfigPerRoute base_filter_config(base_config); + FilterConfigPerRoute specific_filter_config(specific_config); + FilterConfigPerRoute merged_config(base_filter_config, specific_filter_config); + + // Verify context extensions are properly merged. + const auto& merged_extensions = merged_config.contextExtensions(); + EXPECT_EQ(merged_extensions.size(), 3); + EXPECT_EQ(merged_extensions.at("check_key"), "check_value"); + EXPECT_EQ(merged_extensions.at("specific_check_key"), "specific_check_value"); + EXPECT_EQ(merged_extensions.at("shared_key"), + "specific_check_shared_value"); // More specific wins +} + +// Test per-route Google gRPC service configuration. +TEST_P(HttpFilterTestParam, PerRouteGoogleGrpcServiceConfiguration) { + if (std::get<1>(GetParam())) { + // Skip HTTP client test as per-route gRPC service only applies to gRPC clients. + return; + } + + envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute per_route_config; + per_route_config.mutable_check_settings() + ->mutable_grpc_service() + ->mutable_google_grpc() + ->set_target_uri("https://ext-authz.googleapis.com"); + + std::unique_ptr per_route_filter_config = + std::make_unique(per_route_config); + + // Verify Google gRPC service is properly configured + EXPECT_TRUE(per_route_filter_config->grpcService().has_value()); + EXPECT_TRUE(per_route_filter_config->grpcService().value().has_google_grpc()); + EXPECT_EQ(per_route_filter_config->grpcService().value().google_grpc().target_uri(), + "https://ext-authz.googleapis.com"); +} + +// Test existing functionality still works with new logic. +TEST_P(HttpFilterTestParam, ExistingFunctionalityWithNewLogic) { + // Test that the existing functionality still works with our new per-route merging logic. + prepareCheck(); + + // Mock the default client check call (no per-route config). + EXPECT_CALL(*client_, check(_, _, _, _)) + .WillOnce(Invoke([&](Filters::Common::ExtAuthz::RequestCallbacks& callbacks, + const envoy::service::auth::v3::CheckRequest&, Tracing::Span&, + const StreamInfo::StreamInfo&) -> void { + Filters::Common::ExtAuthz::Response response{}; + response.status = Filters::Common::ExtAuthz::CheckStatus::OK; + callbacks.onComplete(std::make_unique(response)); + })); + + EXPECT_CALL(decoder_filter_callbacks_, continueDecoding()).Times(0); + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers_, false)); +} + +// Test per-route configuration merging with empty configurations. +TEST_P(HttpFilterTestParam, PerRouteConfigurationMergingWithEmptyConfigurations) { + if (std::get<1>(GetParam())) { + // Skip HTTP client test as configuration merging applies to gRPC clients. + return; + } + + // Create empty base configuration. + envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute base_config; + + // Create empty specific configuration. + envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute specific_config; + + // Test merging using the merge constructor. + FilterConfigPerRoute base_filter_config(base_config); + FilterConfigPerRoute specific_filter_config(specific_config); + FilterConfigPerRoute merged_config(base_filter_config, specific_filter_config); + + // Verify merged configuration has empty context extensions. + const auto& merged_extensions = merged_config.contextExtensions(); + EXPECT_EQ(merged_extensions.size(), 0); + + // Verify no gRPC service override + EXPECT_FALSE(merged_config.grpcService().has_value()); +} + +// Test per-route gRPC service configuration merging functionality. +TEST_P(HttpFilterTestParam, PerRouteGrpcServiceMergingWithBaseConfiguration) { + if (std::get<1>(GetParam())) { + // Skip HTTP client test as per-route gRPC service only applies to gRPC clients. + return; + } + + // Create base per-route configuration. + envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute base_config; + (*base_config.mutable_check_settings()->mutable_context_extensions())["base"] = "value"; + FilterConfigPerRoute base_filter_config(base_config); + + // Create per-route configuration with gRPC service. + envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute per_route_config; + per_route_config.mutable_check_settings() + ->mutable_grpc_service() + ->mutable_envoy_grpc() + ->set_cluster_name("per_route_cluster"); + (*per_route_config.mutable_check_settings()->mutable_context_extensions())["route"] = "override"; + + // Test merging constructor. + FilterConfigPerRoute merged_config(base_filter_config, per_route_config); + + // Verify the merged configuration has the gRPC service from the per-route config. + EXPECT_TRUE(merged_config.grpcService().has_value()); + EXPECT_TRUE(merged_config.grpcService().value().has_envoy_grpc()); + EXPECT_EQ(merged_config.grpcService().value().envoy_grpc().cluster_name(), "per_route_cluster"); + + // Verify that context extensions are properly merged. + const auto& merged_settings = merged_config.checkSettings(); + EXPECT_TRUE(merged_settings.context_extensions().contains("route")); + EXPECT_EQ(merged_settings.context_extensions().at("route"), "override"); +} + +// Test focused integration test to verify per-route configuration is processed correctly. +TEST_P(HttpFilterTestParam, PerRouteConfigurationIntegrationTest) { + if (std::get<1>(GetParam())) { + // Skip HTTP client test - per-route gRPC service only applies to gRPC clients. + return; + } + + // This test covers the per-route configuration processing in initiateCall + // which exercises the lines where getAllPerFilterConfig is called and processed. + + // Set up per-route configuration with gRPC service override + envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute per_route_config; + per_route_config.mutable_check_settings() + ->mutable_grpc_service() + ->mutable_envoy_grpc() + ->set_cluster_name("per_route_cluster"); + + // Add context extensions to test that path too. + (*per_route_config.mutable_check_settings()->mutable_context_extensions())["test_key"] = + "test_value"; + + std::unique_ptr per_route_filter_config = + std::make_unique(per_route_config); + + // Mock decoder callbacks to return per-route config. + ON_CALL(decoder_filter_callbacks_, mostSpecificPerFilterConfig()) + .WillByDefault(Return(per_route_filter_config.get())); + + // Mock perFilterConfigs to return the per-route config vector. + Router::RouteSpecificFilterConfigs per_route_configs; + per_route_configs.push_back(per_route_filter_config.get()); + ON_CALL(decoder_filter_callbacks_, perFilterConfigs()).WillByDefault(Return(per_route_configs)); + + // Set up basic request headers. + Http::TestRequestHeaderMapImpl headers{ + {":method", "GET"}, {":path", "/test"}, {":scheme", "https"}, {"host", "example.com"}}; + + prepareCheck(); + + // Mock client check to capture and verify the check request has proper context extensions. + EXPECT_CALL(*client_, check(_, _, _, _)) + .WillOnce(Invoke([&](Filters::Common::ExtAuthz::RequestCallbacks& callbacks, + const envoy::service::auth::v3::CheckRequest& check_request, + Tracing::Span&, const StreamInfo::StreamInfo&) -> void { + // Verify that per-route context extensions were merged correctly + auto context_extensions = check_request.attributes().context_extensions(); + EXPECT_TRUE(context_extensions.contains("test_key")); + EXPECT_EQ(context_extensions.at("test_key"), "test_value"); + + // Return OK to complete the test + auto response = std::make_unique(); + response->status = Filters::Common::ExtAuthz::CheckStatus::OK; + callbacks.onComplete(std::move(response)); + })); + + // This exercises the per-route configuration processing logic which includes + // the getAllPerFilterConfig call and per-route gRPC service detection. + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(headers, true)); +} + +// Test per-route gRPC client creation and usage. +TEST_P(HttpFilterTestParam, PerRouteGrpcClientCreationAndUsage) { + if (std::get<1>(GetParam())) { + // Skip HTTP client test as per-route gRPC service only applies to gRPC clients. + return; + } + + // Create per-route configuration with valid gRPC service. + envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute per_route_config; + per_route_config.mutable_check_settings() + ->mutable_grpc_service() + ->mutable_envoy_grpc() + ->set_cluster_name("per_route_ext_authz_cluster"); + + // Add context extensions to test merging. + (*per_route_config.mutable_check_settings()->mutable_context_extensions())["test_key"] = + "test_value"; + + std::unique_ptr per_route_filter_config = + std::make_unique(per_route_config); + + // Set up route to return per-route config. + ON_CALL(*decoder_filter_callbacks_.route_, mostSpecificPerFilterConfig(_)) + .WillByDefault(Return(per_route_filter_config.get())); + + // Mock perFilterConfigs to return the per-route config vector which exercises + // getAllPerFilterConfig. + Router::RouteSpecificFilterConfigs per_route_configs; + per_route_configs.push_back(per_route_filter_config.get()); + ON_CALL(decoder_filter_callbacks_, perFilterConfigs()).WillByDefault(Return(per_route_configs)); + + prepareCheck(); + + // Create a filter with server context for per-route gRPC client creation. + auto new_client = std::make_unique(); + auto* new_client_ptr = new_client.get(); + auto new_filter = std::make_unique(config_, std::move(new_client), factory_context_); + new_filter->setDecoderFilterCallbacks(decoder_filter_callbacks_); + + // Mock successful gRPC async client manager access. + auto mock_grpc_client_manager = std::make_shared(); + ON_CALL(factory_context_, clusterManager()).WillByDefault(ReturnRef(cm_)); + ON_CALL(cm_, grpcAsyncClientManager()).WillByDefault(ReturnRef(*mock_grpc_client_manager)); + + // Mock successful raw gRPC client creation which exercises createPerRouteGrpcClient. + auto mock_raw_grpc_client = std::make_shared(); + EXPECT_CALL(*mock_grpc_client_manager, getOrCreateRawAsyncClientWithHashKey(_, _, true)) + .WillOnce(Return(absl::StatusOr(mock_raw_grpc_client))); + + // Set up expectations for the sendRaw call that will be made by the GrpcClientImpl. + EXPECT_CALL(*mock_raw_grpc_client, sendRaw(_, _, _, _, _, _)) + .WillOnce( + Invoke([](absl::string_view /*service_full_name*/, absl::string_view /*method_name*/, + Buffer::InstancePtr&& /*request*/, Grpc::RawAsyncRequestCallbacks& callbacks, + Tracing::Span& parent_span, + const Http::AsyncClient::RequestOptions& /*options*/) -> Grpc::AsyncRequest* { + envoy::service::auth::v3::CheckResponse check_response; + check_response.mutable_status()->set_code(Grpc::Status::WellKnownGrpcStatus::Ok); + check_response.mutable_ok_response(); + + // Serialize the response to a buffer. + std::string serialized_response; + check_response.SerializeToString(&serialized_response); + auto response = std::make_unique(serialized_response); + + callbacks.onSuccessRaw(std::move(response), parent_span); + return nullptr; // No async request handle needed for immediate response. + })); + + // Since per-route gRPC client creation succeeds, the per-route client should be used + // instead of the default client. We won't see a call to new_client_ptr. + EXPECT_CALL(*new_client_ptr, check(_, _, _, _)).Times(0); + + EXPECT_EQ(Http::FilterHeadersStatus::Continue, + new_filter->decodeHeaders(request_headers_, false)); +} + +// Test per-route HTTP service configuration parsing. +TEST_P(HttpFilterTestParam, PerRouteHttpServiceConfigurationParsing) { + if (!std::get<1>(GetParam())) { + // Skip gRPC client test as per-route HTTP service only applies to HTTP clients. + return; + } + + // Create per-route configuration with valid HTTP service. + envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute per_route_config; + per_route_config.mutable_check_settings()->mutable_http_service()->mutable_server_uri()->set_uri( + "https://per-route-ext-authz.example.com"); + per_route_config.mutable_check_settings() + ->mutable_http_service() + ->mutable_server_uri() + ->set_cluster("per_route_http_cluster"); + per_route_config.mutable_check_settings()->mutable_http_service()->set_path_prefix( + "/api/v2/auth"); + + std::unique_ptr per_route_filter_config = + std::make_unique(per_route_config); + + // Verify the per-route HTTP service configuration is correctly parsed + EXPECT_TRUE(per_route_filter_config->httpService().has_value()); + EXPECT_FALSE(per_route_filter_config->grpcService().has_value()); + + const auto& http_service = per_route_filter_config->httpService().value(); + EXPECT_EQ(http_service.server_uri().uri(), "https://per-route-ext-authz.example.com"); + EXPECT_EQ(http_service.server_uri().cluster(), "per_route_http_cluster"); + EXPECT_EQ(http_service.path_prefix(), "/api/v2/auth"); +} + +// Test error handling when server context is not available for per-route gRPC client. +TEST_P(HttpFilterTestParam, PerRouteGrpcClientCreationNoServerContext) { + if (std::get<1>(GetParam())) { + // Skip HTTP client test - per-route gRPC service only applies to gRPC clients. + return; + } + + // Create per-route configuration with gRPC service. + envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute per_route_config; + per_route_config.mutable_check_settings() + ->mutable_grpc_service() + ->mutable_envoy_grpc() + ->set_cluster_name("per_route_grpc_cluster"); + + std::unique_ptr per_route_filter_config = + std::make_unique(per_route_config); + + ON_CALL(*decoder_filter_callbacks_.route_, mostSpecificPerFilterConfig(_)) + .WillByDefault(Return(per_route_filter_config.get())); + + Router::RouteSpecificFilterConfigs per_route_configs; + per_route_configs.push_back(per_route_filter_config.get()); + ON_CALL(decoder_filter_callbacks_, perFilterConfigs()).WillByDefault(Return(per_route_configs)); + + prepareCheck(); + + // Create filter without server context. This should cause per-route client creation to fail. + auto new_client = std::make_unique(); + auto* new_client_ptr = new_client.get(); + auto new_filter = std::make_unique(config_, std::move(new_client)); // No server context + new_filter->setDecoderFilterCallbacks(decoder_filter_callbacks_); + + // Since per-route client creation fails (no server context), should fall back to default client. + EXPECT_CALL(*new_client_ptr, check(_, _, _, _)) + .WillOnce(Invoke([&](Filters::Common::ExtAuthz::RequestCallbacks& callbacks, + const envoy::service::auth::v3::CheckRequest&, Tracing::Span&, + const StreamInfo::StreamInfo&) -> void { + // Verify this is using the default client. + auto response = std::make_unique(); + response->status = Filters::Common::ExtAuthz::CheckStatus::OK; + callbacks.onComplete(std::move(response)); + })); + + Http::TestRequestHeaderMapImpl request_headers_{ + {":method", "GET"}, {":path", "/test"}, {":scheme", "http"}, {"host", "example.com"}}; + + EXPECT_EQ(Http::FilterHeadersStatus::Continue, + new_filter->decodeHeaders(request_headers_, false)); +} + +// Test error handling when server context is not available for per-route HTTP client. +TEST_P(HttpFilterTestParam, PerRouteHttpClientCreationNoServerContext) { + if (!std::get<1>(GetParam())) { + // Skip gRPC client test as per-route HTTP service only applies to HTTP clients. + return; + } + + // Create per-route configuration with HTTP service. + envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute per_route_config; + per_route_config.mutable_check_settings()->mutable_http_service()->mutable_server_uri()->set_uri( + "https://per-route-ext-authz.example.com"); + per_route_config.mutable_check_settings() + ->mutable_http_service() + ->mutable_server_uri() + ->set_cluster("per_route_http_cluster"); + + std::unique_ptr per_route_filter_config = + std::make_unique(per_route_config); + + ON_CALL(*decoder_filter_callbacks_.route_, mostSpecificPerFilterConfig(_)) + .WillByDefault(Return(per_route_filter_config.get())); + + Router::RouteSpecificFilterConfigs per_route_configs; + per_route_configs.push_back(per_route_filter_config.get()); + ON_CALL(decoder_filter_callbacks_, perFilterConfigs()).WillByDefault(Return(per_route_configs)); + + prepareCheck(); + + // Create filter without server context. + auto new_client = std::make_unique(); + auto* new_client_ptr = new_client.get(); + auto new_filter = std::make_unique(config_, std::move(new_client)); // No server context + new_filter->setDecoderFilterCallbacks(decoder_filter_callbacks_); + + // Since per-route client creation fails, should fall back to default client. + EXPECT_CALL(*new_client_ptr, check(_, _, _, _)) + .WillOnce(Invoke([&](Filters::Common::ExtAuthz::RequestCallbacks& callbacks, + const envoy::service::auth::v3::CheckRequest&, Tracing::Span&, + const StreamInfo::StreamInfo&) -> void { + auto response = std::make_unique(); + response->status = Filters::Common::ExtAuthz::CheckStatus::OK; + callbacks.onComplete(std::move(response)); + })); + + Http::TestRequestHeaderMapImpl request_headers_{ + {":method", "GET"}, {":path", "/test"}, {":scheme", "http"}, {"host", "example.com"}}; + + EXPECT_EQ(Http::FilterHeadersStatus::Continue, + new_filter->decodeHeaders(request_headers_, false)); +} + +// Test gRPC client error handling for per-route config. +TEST_F(HttpFilterTest, GrpcClientPerRouteError) { + // Initialize with gRPC client configuration. + initialize(R"EOF( + grpc_service: + envoy_grpc: + cluster_name: "ext_authz_server" + failure_mode_allow: false + stat_prefix: "ext_authz" + )EOF"); + + prepareCheck(); + + // Create per-route configuration with gRPC service override. + envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute per_route_config; + auto* grpc_service = per_route_config.mutable_check_settings()->mutable_grpc_service(); + grpc_service->mutable_envoy_grpc()->set_cluster_name("nonexistent_cluster"); + + FilterConfigPerRoute per_route_filter_config(per_route_config); + + // Set up route config to use the per-route configuration. + ON_CALL(decoder_filter_callbacks_, mostSpecificPerFilterConfig()) + .WillByDefault(Return(&per_route_filter_config)); + + // Since cluster doesn't exist, per-route client creation should fail + // and we'll use the default client instead. + EXPECT_CALL(*client_, check(_, _, _, _)) + .WillOnce(Invoke([&](Filters::Common::ExtAuthz::RequestCallbacks& callbacks, + const envoy::service::auth::v3::CheckRequest&, Tracing::Span&, + const StreamInfo::StreamInfo&) -> void { + Filters::Common::ExtAuthz::Response response{}; + response.status = Filters::Common::ExtAuthz::CheckStatus::OK; + callbacks.onComplete(std::make_unique(response)); + })); + + // Verify filter processes the request with the default client. + Http::TestRequestHeaderMapImpl request_headers{ + {":method", "GET"}, {":path", "/test"}, {":scheme", "http"}, {"host", "example.com"}}; + + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, true)); +} + +// Test HTTP client with per-route configuration. +TEST_F(HttpFilterTest, HttpClientPerRouteOverride) { + // Initialize with HTTP client configuration. + initialize(R"EOF( + http_service: + server_uri: + uri: "https://ext-authz.example.com" + cluster: "ext_authz_server" + path_prefix: "/api/v1/auth" + failure_mode_allow: false + stat_prefix: "ext_authz" + )EOF"); + + prepareCheck(); + + // Create per-route configuration with HTTP service override. + envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute per_route_config; + auto* http_service = per_route_config.mutable_check_settings()->mutable_http_service(); + http_service->mutable_server_uri()->set_uri("https://per-route-ext-authz.example.com"); + http_service->mutable_server_uri()->set_cluster("per_route_http_cluster"); + http_service->set_path_prefix("/api/v2/auth"); + + FilterConfigPerRoute per_route_filter_config(per_route_config); + + // Set up route config to use the per-route configuration. + ON_CALL(decoder_filter_callbacks_, mostSpecificPerFilterConfig()) + .WillByDefault(Return(&per_route_filter_config)); + + // Set up a check expectation that will be satisfied by the default client. + EXPECT_CALL(*client_, check(_, _, _, _)) + .WillOnce(Invoke([&](Filters::Common::ExtAuthz::RequestCallbacks& callbacks, + const envoy::service::auth::v3::CheckRequest&, Tracing::Span&, + const StreamInfo::StreamInfo&) -> void { + Filters::Common::ExtAuthz::Response response{}; + response.status = Filters::Common::ExtAuthz::CheckStatus::OK; + callbacks.onComplete(std::make_unique(response)); + })); + + // Verify filter processes the request. + Http::TestRequestHeaderMapImpl request_headers{ + {":method", "GET"}, {":path", "/test"}, {":scheme", "http"}, {"host", "example.com"}}; + + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, true)); +} + +// Test invalid response header validation via response_headers_to_add. +TEST_F(InvalidMutationTest, InvalidResponseHeadersToAddName) { + Filters::Common::ExtAuthz::Response r; + r.status = Filters::Common::ExtAuthz::CheckStatus::OK; + r.response_headers_to_add = {{"invalid header name", "value"}}; + testResponse(r); +} + +// Test invalid response header validation via response_headers_to_add value. +TEST_F(InvalidMutationTest, InvalidResponseHeadersToAddValue) { + Filters::Common::ExtAuthz::Response r; + r.status = Filters::Common::ExtAuthz::CheckStatus::OK; + r.response_headers_to_add = {{"valid-name", getInvalidValue()}}; + testResponse(r); +} + +// Test per-route timeout configuration is correctly used in gRPC client creation. +TEST_P(HttpFilterTestParam, PerRouteGrpcClientTimeoutConfiguration) { + if (std::get<1>(GetParam())) { + // Skip HTTP client test as per-route gRPC service only applies to gRPC clients. + return; + } + + // Create per-route configuration with custom timeout. + envoy::extensions::filters::http::ext_authz::v3::ExtAuthzPerRoute per_route_config; + auto* grpc_service = per_route_config.mutable_check_settings()->mutable_grpc_service(); + grpc_service->mutable_envoy_grpc()->set_cluster_name("per_route_grpc_cluster"); + grpc_service->mutable_timeout()->set_seconds(30); // Custom 30s timeout + + std::unique_ptr per_route_filter_config = + std::make_unique(per_route_config); + + ON_CALL(*decoder_filter_callbacks_.route_, mostSpecificPerFilterConfig(_)) + .WillByDefault(Return(per_route_filter_config.get())); + + Router::RouteSpecificFilterConfigs per_route_configs; + per_route_configs.push_back(per_route_filter_config.get()); + ON_CALL(decoder_filter_callbacks_, perFilterConfigs()).WillByDefault(Return(per_route_configs)); + + prepareCheck(); + + auto new_client = std::make_unique(); + auto* new_client_ptr = new_client.get(); + auto new_filter = std::make_unique(config_, std::move(new_client), factory_context_); + new_filter->setDecoderFilterCallbacks(decoder_filter_callbacks_); + + // Mock gRPC client manager. + auto mock_grpc_client_manager = std::make_shared(); + ON_CALL(factory_context_, clusterManager()).WillByDefault(ReturnRef(cm_)); + ON_CALL(cm_, grpcAsyncClientManager()).WillByDefault(ReturnRef(*mock_grpc_client_manager)); + + auto mock_raw_grpc_client = std::make_shared(); + EXPECT_CALL(*mock_grpc_client_manager, getOrCreateRawAsyncClientWithHashKey(_, _, true)) + .WillOnce(Return(absl::StatusOr(mock_raw_grpc_client))); + + // Mock the sendRaw call and verify the timeout is used correctly. + EXPECT_CALL(*mock_raw_grpc_client, sendRaw(_, _, _, _, _, _)) + .WillOnce(Invoke([](absl::string_view, absl::string_view, Buffer::InstancePtr&&, + Grpc::RawAsyncRequestCallbacks& callbacks, Tracing::Span& parent_span, + const Http::AsyncClient::RequestOptions& options) -> Grpc::AsyncRequest* { + // Verify that the timeout from the per-route config is used (30s = 30000ms) + EXPECT_TRUE(options.timeout.has_value()); + EXPECT_EQ(options.timeout->count(), 30000); + + envoy::service::auth::v3::CheckResponse check_response; + check_response.mutable_status()->set_code(Grpc::Status::WellKnownGrpcStatus::Ok); + check_response.mutable_ok_response(); + + std::string serialized_response; + check_response.SerializeToString(&serialized_response); + auto response = std::make_unique(serialized_response); + + callbacks.onSuccessRaw(std::move(response), parent_span); + return nullptr; + })); + + EXPECT_CALL(*new_client_ptr, check(_, _, _, _)).Times(0); + + Http::TestRequestHeaderMapImpl request_headers_{ + {":method", "GET"}, {":path", "/test"}, {":scheme", "http"}, {"host", "example.com"}}; + + EXPECT_EQ(Http::FilterHeadersStatus::Continue, + new_filter->decodeHeaders(request_headers_, false)); +} + } // namespace } // namespace ExtAuthz } // namespace HttpFilters