From ab52b392c8d473c824f1554bb52202ca975720dc Mon Sep 17 00:00:00 2001 From: Todd Greer Date: Tue, 5 May 2026 22:29:49 +0000 Subject: [PATCH 01/10] ext_authz: implement cooperative caching bypass Enables cooperative caching between L7 ext_authz filter and CONE caching filter. - Updated ext_authz.proto with warning documentation. - Added raw_check_response to internal Response struct. - Implemented tryCacheHit in L7 filter to bypass external call. - Populated raw_check_response in gRPC and HTTP clients (with HTTP synthesis). - Recorded CheckResponse to dynamic metadata in L7 filter onComplete. - Added invalid_cached_response stat. - Added extensive unit tests. Signed-off-by: Todd Greer --- .../filters/http/ext_authz/v3/ext_authz.proto | 12 +- .../extensions/filters/common/ext_authz/BUILD | 2 + .../filters/common/ext_authz/ext_authz.h | 2 + .../common/ext_authz/ext_authz_grpc_impl.cc | 1 + .../common/ext_authz/ext_authz_http_impl.cc | 68 ++++- .../filters/http/ext_authz/ext_authz.cc | 182 +++++++++++- .../filters/http/ext_authz/ext_authz.h | 8 +- .../ext_authz/ext_authz_grpc_impl_test.cc | 25 ++ .../ext_authz/ext_authz_http_impl_test.cc | 20 ++ test/extensions/filters/http/ext_authz/BUILD | 26 ++ .../http/ext_authz/ext_authz_cache_test.cc | 262 ++++++++++++++++++ 11 files changed, 603 insertions(+), 5 deletions(-) create mode 100644 test/extensions/filters/http/ext_authz/ext_authz_cache_test.cc 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 2cc6e689b6e20..828a7f449f0f9 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 @@ -30,7 +30,7 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE; // External Authorization :ref:`configuration overview `. // [#extension: envoy.filters.http.ext_authz] -// [#next-free-field: 33] +// [#next-free-field: 34] message ExtAuthz { option (udpa.annotations.versioning).previous_message_type = "envoy.config.filter.http.ext_authz.v3.ExtAuthz"; @@ -386,6 +386,16 @@ message ExtAuthz { // // Defaults to ``false``. bool shadow_mode = 32; + + // [WARNING]: Users must ensure that the configured key does not conflict with + // keys returned by the authorization server itself in its dynamic metadata + // (either via the dynamic_metadata field in CheckResponse or headers mapped to + // dynamic metadata). A collision could allow an external server to overwrite + // or corrupt the cache, potentially leading to security bypasses or unexpected behavior. + // + // If, during ``decodeHeaders``, this dynamic metadata key contains a serialized CheckResponse, + // the filter will apply that CheckResponse as if it had been returned by the external service (which won't be called). + string check_response_metadata_key = 33; } // Serialized form of the shadow-mode authorization decision written to FilterState diff --git a/source/extensions/filters/common/ext_authz/BUILD b/source/extensions/filters/common/ext_authz/BUILD index fe5d536b437ff..c76593d5f8143 100644 --- a/source/extensions/filters/common/ext_authz/BUILD +++ b/source/extensions/filters/common/ext_authz/BUILD @@ -85,3 +85,5 @@ envoy_cc_library( "@envoy_api//envoy/service/auth/v3:pkg_cc_proto", ], ) + + diff --git a/source/extensions/filters/common/ext_authz/ext_authz.h b/source/extensions/filters/common/ext_authz/ext_authz.h index 208cbe12b6973..186592f634fa1 100644 --- a/source/extensions/filters/common/ext_authz/ext_authz.h +++ b/source/extensions/filters/common/ext_authz/ext_authz.h @@ -133,6 +133,8 @@ struct Response { // The gRPC status returned by the authorization server when it is making a // gRPC call. absl::optional grpc_status{absl::nullopt}; + // The raw CheckResponse proto, if available. + absl::optional raw_check_response; }; using ResponsePtr = std::unique_ptr; diff --git a/source/extensions/filters/common/ext_authz/ext_authz_grpc_impl.cc b/source/extensions/filters/common/ext_authz/ext_authz_grpc_impl.cc index aa8e0a1d96858..0f5650c95e9a8 100644 --- a/source/extensions/filters/common/ext_authz/ext_authz_grpc_impl.cc +++ b/source/extensions/filters/common/ext_authz/ext_authz_grpc_impl.cc @@ -110,6 +110,7 @@ void GrpcClientImpl::onSuccess(std::unique_ptrDebugString()); ResponsePtr authz_response = std::make_unique(Response{}); authz_response->grpc_status = response->status().code(); + authz_response->raw_check_response = *response; if (response->status().code() == Grpc::Status::WellKnownGrpcStatus::Ok) { span.setTag(TracingConstants::get().TraceStatus, TracingConstants::get().TraceOk); authz_response->status = CheckStatus::OK; 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 e8e9d07795a9c..1894c52a4d0ef 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 @@ -427,7 +427,13 @@ ResponsePtr RawHttpClientImpl::toResponse(Http::ResponseMessagePtr message) { // codes. A Forbidden response is sent to the client if the filter has not been configured with // failure_mode_allow. if (Http::CodeUtility::is5xx(status_code)) { - return std::make_unique(errorResponse()); + auto response = std::make_unique(errorResponse()); + envoy::service::auth::v3::CheckResponse raw_check_response; + raw_check_response.mutable_status()->set_code(Grpc::Status::WellKnownGrpcStatus::Internal); + auto* error_response = raw_check_response.mutable_error_response(); + error_response->mutable_status()->set_code(static_cast(status_code)); + response->raw_check_response = raw_check_response; + return response; } // Extract headers-to-remove from the storage header coming from the @@ -477,6 +483,37 @@ ResponsePtr RawHttpClientImpl::toResponse(Http::ResponseMessagePtr message) { EMPTY_STRING, Http::Code::OK, Protobuf::Struct{}}}; + + envoy::service::auth::v3::CheckResponse raw_check_response; + raw_check_response.mutable_status()->set_code(Grpc::Status::WellKnownGrpcStatus::Ok); + auto* ok_response = raw_check_response.mutable_ok_response(); + + for (const auto& header : ok.response_->headers_to_set) { + auto* h = ok_response->add_headers(); + h->mutable_header()->set_key(header.first); + h->mutable_header()->set_value(header.second); + h->mutable_append()->set_value(false); + } + for (const auto& header : ok.response_->headers_to_add) { + auto* h = ok_response->add_headers(); + h->mutable_header()->set_key(header.first); + h->mutable_header()->set_value(header.second); + h->mutable_append()->set_value(true); + } + for (const auto& header : ok.response_->response_headers_to_add) { + auto* h = ok_response->add_response_headers_to_add(); + h->mutable_header()->set_key(header.first); + h->mutable_header()->set_value(header.second); + h->set_append_action(Router::HeaderValueOption::APPEND_IF_EXISTS_OR_ADD); + } + for (const auto& header : ok.response_->headers_to_remove) { + ok_response->add_headers_to_remove(header); + } + if (!ok.response_->dynamic_metadata.fields().empty()) { + *ok_response->mutable_dynamic_metadata() = ok.response_->dynamic_metadata; + } + + ok.response_->raw_check_response = raw_check_response; return std::move(ok.response_); } @@ -504,6 +541,35 @@ ResponsePtr RawHttpClientImpl::toResponse(Http::ResponseMessagePtr message) { message->bodyAsString(), static_cast(status_code), Protobuf::Struct{}}}; + + envoy::service::auth::v3::CheckResponse raw_check_response; + const auto grpc_status = (status_code == enumToInt(Http::Code::Unauthorized)) + ? Grpc::Status::WellKnownGrpcStatus::Unauthenticated + : Grpc::Status::WellKnownGrpcStatus::PermissionDenied; + raw_check_response.mutable_status()->set_code(grpc_status); + + auto* denied_response = raw_check_response.mutable_denied_response(); + denied_response->mutable_status()->set_code(static_cast(status_code)); + denied_response->set_body(denied.response_->body); + + for (const auto& header : denied.response_->headers_to_set) { + auto* h = denied_response->add_headers(); + h->mutable_header()->set_key(header.first); + h->mutable_header()->set_value(header.second); + h->mutable_append()->set_value(false); + } + for (const auto& header : denied.response_->headers_to_append) { + auto* h = denied_response->add_headers(); + h->mutable_header()->set_key(header.first); + h->mutable_header()->set_value(header.second); + h->mutable_append()->set_value(true); + } + + if (!denied.response_->dynamic_metadata.fields().empty()) { + *raw_check_response.mutable_dynamic_metadata() = denied.response_->dynamic_metadata; + } + + denied.response_->raw_check_response = raw_check_response; return std::move(denied.response_); } diff --git a/source/extensions/filters/http/ext_authz/ext_authz.cc b/source/extensions/filters/http/ext_authz/ext_authz.cc index 404093d2f704f..9528d6e7f870d 100644 --- a/source/extensions/filters/http/ext_authz/ext_authz.cc +++ b/source/extensions/filters/http/ext_authz/ext_authz.cc @@ -5,6 +5,8 @@ #include #include +#include "absl/strings/escaping.h" + #include "envoy/config/core/v3/base.pb.h" #include "source/common/common/assert.h" @@ -75,6 +77,68 @@ bool headersWithinLimits(const Http::HeaderMap& headers) { headers.byteSize() <= headers.maxHeadersKb() * 1024; } +void copyHeaderFieldIntoResponse( + Filters::Common::ExtAuthz::ResponsePtr& response, + const Protobuf::RepeatedPtrField& headers) { + for (const auto& header : headers) { + if (header.append().value()) { + response->headers_to_append.emplace_back(header.header().key(), header.header().value()); + } else { + response->headers_to_set.emplace_back(header.header().key(), header.header().value()); + } + } +} + +void copyOkResponseMutations(Filters::Common::ExtAuthz::ResponsePtr& response, + const envoy::service::auth::v3::OkHttpResponse& ok_response) { + copyHeaderFieldIntoResponse(response, ok_response.headers()); + + for (const auto& header : ok_response.response_headers_to_add()) { + if (header.has_append()) { + if (header.append().value()) { + response->response_headers_to_add.emplace_back(header.header().key(), + header.header().value()); + } else { + response->response_headers_to_set.emplace_back(header.header().key(), + header.header().value()); + } + } else { + switch (header.append_action()) { + case Router::HeaderValueOption::APPEND_IF_EXISTS_OR_ADD: + response->response_headers_to_add.emplace_back(header.header().key(), + header.header().value()); + break; + case Router::HeaderValueOption::ADD_IF_ABSENT: + response->response_headers_to_add_if_absent.emplace_back(header.header().key(), + header.header().value()); + break; + case Router::HeaderValueOption::OVERWRITE_IF_EXISTS: + response->response_headers_to_overwrite_if_exists.emplace_back(header.header().key(), + header.header().value()); + break; + case Router::HeaderValueOption::OVERWRITE_IF_EXISTS_OR_ADD: + response->response_headers_to_set.emplace_back(header.header().key(), + header.header().value()); + break; + default: + response->saw_invalid_append_actions = true; + break; + } + } + } + + response->headers_to_remove = std::vector{ok_response.headers_to_remove().begin(), + ok_response.headers_to_remove().end()}; + + for (const auto& query_parameter : ok_response.query_parameters_to_set()) { + response->query_parameters_to_set.emplace_back(query_parameter.key(), query_parameter.value()); + } + + response->query_parameters_to_remove = + std::vector{ok_response.query_parameters_to_remove().begin(), + ok_response.query_parameters_to_remove().end()}; +} + } // namespace FilterConfig::FilterConfig(const envoy::extensions::filters::http::ext_authz::v3::ExtAuthz& config, @@ -136,6 +200,7 @@ FilterConfig::FilterConfig(const envoy::extensions::filters::http::ext_authz::v3 include_tls_session_(config.include_tls_session()), charge_cluster_response_stats_( PROTOBUF_GET_WRAPPED_OR_DEFAULT(config, charge_cluster_response_stats, true)), + check_response_metadata_key_(config.check_response_metadata_key()), stats_(generateStats(stats_prefix, config.stat_prefix(), scope)), ext_authz_ok_(pool_.add(createPoolStatName(config.stat_prefix(), "ok"))), ext_authz_denied_(pool_.add(createPoolStatName(config.stat_prefix(), "denied"))), @@ -397,6 +462,93 @@ void Filter::initiateCall(const Http::RequestHeaderMap& headers) { initiating_call_ = false; } +bool Filter::tryCacheHit() { + const std::string& metadata_key = config_->checkResponseMetadataKey(); + if (metadata_key.empty()) { + return false; + } + + const auto& metadata = decoder_callbacks_->streamInfo().dynamicMetadata(); + const auto& filter_metadata = metadata.filter_metadata(); + const auto metadata_it = filter_metadata.find("envoy.filters.http.ext_authz"); + if (metadata_it == filter_metadata.end()) { + return false; + } + + const auto& fields = metadata_it->second.fields(); + const auto field_it = fields.find(metadata_key); + if (field_it == fields.end()) { + return false; + } + + std::string unescaped; + envoy::service::auth::v3::CheckResponse check_response; + if (!absl::Base64Unescape(field_it->second.string_value(), &unescaped)) { + ENVOY_STREAM_LOG(warn, "ext_authz failed to Base64 decode cached response in metadata key {}", + *decoder_callbacks_, metadata_key); + stats_.invalid_cached_response_.inc(); + return false; + } + + if (!check_response.ParseFromString(unescaped)) { + ENVOY_STREAM_LOG(warn, "ext_authz failed to parse cached CheckResponse in metadata key {}", + *decoder_callbacks_, metadata_key); + stats_.invalid_cached_response_.inc(); + return false; + } + + ENVOY_STREAM_LOG(debug, "ext_authz found cached CheckResponse in metadata key {}", + *decoder_callbacks_, metadata_key); + + Filters::Common::ExtAuthz::ResponsePtr authz_response = + std::make_unique( + Filters::Common::ExtAuthz::Response{}); + + authz_response->grpc_status = check_response.status().code(); + authz_response->raw_check_response = check_response; + if (check_response.status().code() == Grpc::Status::WellKnownGrpcStatus::Ok) { + authz_response->status = Filters::Common::ExtAuthz::CheckStatus::OK; + if (check_response.has_ok_response()) { + copyOkResponseMutations(authz_response, check_response.ok_response()); + } + } else if (check_response.has_error_response()) { + authz_response->status = Filters::Common::ExtAuthz::CheckStatus::Error; + const auto& error_response = check_response.error_response(); + copyHeaderFieldIntoResponse(authz_response, error_response.headers()); + const uint32_t status_code = error_response.status().code(); + if (status_code > 0) { + authz_response->status_code = static_cast(status_code); + } + authz_response->body = error_response.body(); + } else { + authz_response->status = Filters::Common::ExtAuthz::CheckStatus::Denied; + authz_response->status_code = Http::Code::Forbidden; + if (check_response.has_denied_response()) { + copyHeaderFieldIntoResponse(authz_response, + check_response.denied_response().headers()); + const uint32_t status_code = check_response.denied_response().status().code(); + if (status_code > 0) { + authz_response->status_code = static_cast(status_code); + } + authz_response->body = check_response.denied_response().body(); + } + } + + if (check_response.has_ok_response() && + check_response.ok_response().has_dynamic_metadata()) { + authz_response->dynamic_metadata = check_response.ok_response().dynamic_metadata(); + } else { + authz_response->dynamic_metadata = check_response.dynamic_metadata(); + } + + initiating_call_ = true; + filter_return_ = FilterReturn::StopDecoding; + applyResponse(std::move(authz_response)); + initiating_call_ = false; + + return true; +} + Http::FilterHeadersStatus Filter::decodeHeaders(Http::RequestHeaderMap& headers, bool end_stream) { const auto per_route_flags = getPerRouteFlags(decoder_callbacks_->route()); skip_check_ = per_route_flags.skip_check_; @@ -435,6 +587,13 @@ Http::FilterHeadersStatus Filter::decodeHeaders(Http::RequestHeaderMap& headers, } request_headers_ = &headers; + + if (tryCacheHit()) { + return filter_return_ == FilterReturn::StopDecoding + ? Http::FilterHeadersStatus::StopAllIterationAndWatermark + : Http::FilterHeadersStatus::Continue; + } + const auto& check_settings = per_route_flags.check_settings_; buffer_data_ = (config_->withRequestBody() || check_settings.has_with_request_body()) && !check_settings.disable_request_body_buffering() && @@ -651,11 +810,30 @@ CheckResult Filter::validateAndCheckDecoderHeaderMutation( void Filter::onComplete(Filters::Common::ExtAuthz::ResponsePtr&& response) { state_ = State::Complete; - using Filters::Common::ExtAuthz::CheckStatus; - Stats::StatName empty_stat_name; updateLoggingInfo(response->grpc_status); + const std::string& metadata_key = config_->checkResponseMetadataKey(); + if (!metadata_key.empty() && response->raw_check_response.has_value()) { + std::string serialized_response; + if (response->raw_check_response->SerializeToString(&serialized_response)) { + Protobuf::Struct struct_value; + (*struct_value.mutable_fields())[metadata_key].set_string_value( + absl::Base64Escape(serialized_response)); + decoder_callbacks_->streamInfo().setDynamicMetadata("envoy.filters.http.ext_authz", + struct_value); + ENVOY_STREAM_LOG(debug, "ext_authz stored CheckResponse in metadata key {}", + *decoder_callbacks_, metadata_key); + } + } + + applyResponse(std::move(response)); +} + +void Filter::applyResponse(Filters::Common::ExtAuthz::ResponsePtr response) { + using Filters::Common::ExtAuthz::CheckStatus; + Stats::StatName empty_stat_name; + if (response->saw_invalid_append_actions) { if (config_->validateMutations()) { ENVOY_STREAM_LOG(trace, "Rejecting response with invalid header append action.", diff --git a/source/extensions/filters/http/ext_authz/ext_authz.h b/source/extensions/filters/http/ext_authz/ext_authz.h index 0d937d1033a1b..7417534366131 100644 --- a/source/extensions/filters/http/ext_authz/ext_authz.h +++ b/source/extensions/filters/http/ext_authz/ext_authz.h @@ -52,7 +52,8 @@ namespace ExtAuthz { COUNTER(request_header_limits_reached) \ COUNTER(response_header_limits_reached) \ COUNTER(shadow_denied) \ - COUNTER(shadow_error) + COUNTER(shadow_error) \ + COUNTER(invalid_cached_response) /** * Wrapper struct for ext_authz filter stats. @see stats_macros.h @@ -279,6 +280,8 @@ class FilterConfig { bool chargeClusterResponseStats() const { return charge_cluster_response_stats_; } + const std::string& checkResponseMetadataKey() const { return check_response_metadata_key_; } + const Filters::Common::ExtAuthz::MatcherSharedPtr& allowedHeadersMatcher() const { return allowed_headers_matcher_; } @@ -348,6 +351,7 @@ class FilterConfig { const bool include_peer_certificate_; const bool include_tls_session_; const bool charge_cluster_response_stats_; + const std::string check_response_metadata_key_; // The stats for the filter. ExtAuthzFilterStats stats_; @@ -532,6 +536,8 @@ class Filter : public Logger::Loggable, // by non-const reference so we can std::move ``headers_to_set`` into the object instead // of copying. void setShadowFilterState(Filters::Common::ExtAuthz::Response& response); + void applyResponse(Filters::Common::ExtAuthz::ResponsePtr response); + bool tryCacheHit(); bool isBufferFull(uint64_t num_bytes_processing) const; void updateLoggingInfo(const absl::optional& grpc_status); void updateEffect(const Filters::Common::ProcessingEffect::Effect effect); diff --git a/test/extensions/filters/common/ext_authz/ext_authz_grpc_impl_test.cc b/test/extensions/filters/common/ext_authz/ext_authz_grpc_impl_test.cc index 74be98da059a0..b52f190bf5ac2 100644 --- a/test/extensions/filters/common/ext_authz/ext_authz_grpc_impl_test.cc +++ b/test/extensions/filters/common/ext_authz/ext_authz_grpc_impl_test.cc @@ -101,6 +101,31 @@ TEST_F(ExtAuthzGrpcClientTest, AuthorizationOk) { client_->onSuccess(std::move(check_response), span_); } +TEST_F(ExtAuthzGrpcClientTest, AuthorizationOkRawResponsePopulated) { + initialize(); + + auto check_response = std::make_unique(); + check_response->mutable_status()->set_code(Grpc::Status::WellKnownGrpcStatus::Ok); + auto* metadata_fields = check_response->mutable_ok_response()->mutable_dynamic_metadata()->mutable_fields(); + (*metadata_fields)["foo"] = ValueUtil::stringValue("ok"); + + envoy::service::auth::v3::CheckResponse expected_response = *check_response; + + envoy::service::auth::v3::CheckRequest request; + expectCallSend(request); + client_->check(request_callbacks_, request, Tracing::NullSpan::instance(), stream_info_); + + EXPECT_CALL(span_, setTag(Eq("ext_authz_status"), Eq("ext_authz_ok"))); + EXPECT_CALL(request_callbacks_, onComplete_(_)) + .WillOnce(Invoke([expected_response](ResponsePtr& response) { + EXPECT_EQ(CheckStatus::OK, response->status); + ASSERT_TRUE(response->raw_check_response.has_value()); + EXPECT_TRUE(TestUtility::protoEqual(expected_response, response->raw_check_response.value())); + })); + + client_->onSuccess(std::move(check_response), span_); +} + TEST_F(ExtAuthzGrpcClientTest, StreamInfo) { initialize(); 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 767c68cde2002..9b1e6513e48b1 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 @@ -465,6 +465,26 @@ TEST_F(ExtAuthzHttpClientTest, AuthorizationOk) { client_->onSuccess(async_request_, std::move(check_response)); } +TEST_F(ExtAuthzHttpClientTest, AuthorizationOkRawResponseSynthesized) { + const auto expected_headers = TestCommon::makeHeaderValueOption({{":status", "200", false}, {"x-upstream-ok", "yes", false}}); + auto check_response = TestCommon::makeMessageResponse(expected_headers); + envoy::service::auth::v3::CheckRequest request; + client_->check(request_callbacks_, request, parent_span_, stream_info_); + + EXPECT_CALL(request_callbacks_, onComplete_(_)) + .WillOnce(Invoke([](ResponsePtr& response) { + EXPECT_EQ(CheckStatus::OK, response->status); + ASSERT_TRUE(response->raw_check_response.has_value()); + const auto& raw = response->raw_check_response.value(); + EXPECT_EQ(Grpc::Status::WellKnownGrpcStatus::Ok, raw.status().code()); + ASSERT_TRUE(raw.has_ok_response()); + ASSERT_EQ(1, raw.ok_response().headers_size()); + EXPECT_EQ("x-upstream-ok", raw.ok_response().headers(0).header().key()); + EXPECT_EQ("yes", raw.ok_response().headers(0).header().value()); + })); + client_->onSuccess(async_request_, std::move(check_response)); +} + using HeaderValuePair = std::pair; // Verify client response headers when authorization_headers_to_add is configured. diff --git a/test/extensions/filters/http/ext_authz/BUILD b/test/extensions/filters/http/ext_authz/BUILD index 56be0192ba29e..488760848874e 100644 --- a/test/extensions/filters/http/ext_authz/BUILD +++ b/test/extensions/filters/http/ext_authz/BUILD @@ -48,6 +48,32 @@ envoy_extension_cc_test( ], ) +envoy_extension_cc_test( + name = "ext_authz_cache_test", + srcs = ["ext_authz_cache_test.cc"], + extension_names = ["envoy.filters.http.ext_authz"], + rbe_pool = "6gig", + deps = [ + "//envoy/http:codes_interface", + "//source/common/buffer:buffer_lib", + "//source/common/common:empty_string", + "//source/common/http:headers_lib", + "//source/common/protobuf:utility_lib", + "//source/extensions/filters/http/ext_authz", + "//test/extensions/filters/common/ext_authz:ext_authz_mocks", + "//test/mocks/http:http_mocks", + "//test/mocks/network:network_mocks", + "//test/mocks/runtime:runtime_mocks", + "//test/mocks/server:server_factory_context_mocks", + "//test/mocks/tracing:tracing_mocks", + "//test/mocks/upstream:cluster_manager_mocks", + "//test/test_common:utility_lib", + "@envoy_api//envoy/config/core/v3:pkg_cc_proto", + "@envoy_api//envoy/extensions/filters/http/ext_authz/v3:pkg_cc_proto", + "@envoy_api//envoy/service/auth/v3:pkg_cc_proto", + ], +) + envoy_extension_cc_test( name = "config_test", srcs = ["config_test.cc"], diff --git a/test/extensions/filters/http/ext_authz/ext_authz_cache_test.cc b/test/extensions/filters/http/ext_authz/ext_authz_cache_test.cc new file mode 100644 index 0000000000000..67c4155e89ace --- /dev/null +++ b/test/extensions/filters/http/ext_authz/ext_authz_cache_test.cc @@ -0,0 +1,262 @@ +#include +#include +#include +#include + +#include "envoy/config/core/v3/base.pb.h" +#include "envoy/extensions/filters/http/ext_authz/v3/ext_authz.pb.h" +#include "envoy/http/codes.h" +#include "envoy/service/auth/v3/external_auth.pb.h" + +#include "source/common/buffer/buffer_impl.h" +#include "source/common/common/empty_string.h" +#include "source/common/http/headers.h" +#include "source/common/network/address_impl.h" +#include "source/common/protobuf/utility.h" +#include "source/extensions/filters/http/ext_authz/ext_authz.h" + +#include "test/extensions/filters/common/ext_authz/mocks.h" +#include "test/mocks/http/mocks.h" +#include "test/mocks/network/mocks.h" +#include "test/mocks/router/mocks.h" +#include "test/mocks/runtime/mocks.h" +#include "test/mocks/server/server_factory_context.h" +#include "test/mocks/tracing/mocks.h" +#include "test/mocks/upstream/cluster_manager.h" +#include "test/test_common/utility.h" + +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +using Envoy::Http::LowerCaseString; +using testing::_; +using testing::Invoke; +using testing::NiceMock; +using testing::Return; +using testing::ReturnRef; + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace ExtAuthz { +namespace { + +class ExtAuthzCacheTest : public testing::Test { +public: + ExtAuthzCacheTest() {} + + void initialize(const std::string& yaml) { + envoy::extensions::filters::http::ext_authz::v3::ExtAuthz proto_config{}; + if (!yaml.empty()) { + TestUtility::loadFromYaml(yaml, proto_config); + } + 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_}, + factory_context_); + ON_CALL(decoder_filter_callbacks_, filterConfigName()).WillByDefault(Return("ext_authz")); + filter_->setDecoderFilterCallbacks(decoder_filter_callbacks_); + filter_->setEncoderFilterCallbacks(encoder_filter_callbacks_); + addr_ = std::make_shared("1.2.3.4", 1111); + } + + void prepareCheck() { + ON_CALL(decoder_filter_callbacks_, connection()) + .WillByDefault(Return(OptRef{connection_})); + connection_.stream_info_.downstream_connection_info_provider_->setRemoteAddress(addr_); + connection_.stream_info_.downstream_connection_info_provider_->setLocalAddress(addr_); + } + + void setCacheMetadata(const envoy::service::auth::v3::CheckResponse& response, const std::string& key) { + std::string serialized; + ASSERT_TRUE(response.SerializeToString(&serialized)); + std::string encoded = absl::Base64Escape(serialized); + + Protobuf::Struct struct_value; + (*struct_value.mutable_fields())[key].set_string_value(encoded); + + // Set it in dynamic metadata + decoder_callbacks_metadata_.mutable_filter_metadata()->insert( + {"envoy.filters.http.ext_authz", struct_value}); + + ON_CALL(decoder_filter_callbacks_.stream_info_, dynamicMetadata()) + .WillByDefault(ReturnRef(decoder_callbacks_metadata_)); + } + + NiceMock stats_store_; + FilterConfigSharedPtr config_; + Filters::Common::ExtAuthz::MockClient* client_; + std::unique_ptr filter_; + NiceMock decoder_filter_callbacks_; + NiceMock encoder_filter_callbacks_; + Http::TestRequestHeaderMapImpl request_headers_; + NiceMock factory_context_; + Network::Address::InstanceConstSharedPtr addr_; + NiceMock connection_; + envoy::config::core::v3::Metadata decoder_callbacks_metadata_; +}; + +TEST_F(ExtAuthzCacheTest, CacheHitOK) { + initialize(R"( + grpc_service: + envoy_grpc: + cluster_name: "ext_authz_server" + check_response_metadata_key: "authz_cache" + )"); + + prepareCheck(); + + // Prepare cached response + envoy::service::auth::v3::CheckResponse cached_response; + cached_response.mutable_status()->set_code(Grpc::Status::WellKnownGrpcStatus::Ok); + auto* ok_response = cached_response.mutable_ok_response(); + auto* header = ok_response->add_headers(); + header->mutable_header()->set_key("x-cached-header"); + header->mutable_header()->set_value("yes"); + + setCacheMetadata(cached_response, "authz_cache"); + + // We expect client_->check to NOT be called + EXPECT_CALL(*client_, check(_, _, _, _)).Times(0); + + // Call decodeHeaders + request_headers_.addCopy(Http::Headers::get().Host, "example.com"); + request_headers_.addCopy(Http::Headers::get().Method, "GET"); + request_headers_.addCopy(Http::Headers::get().Path, "/"); + + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers_, false)); + + // Verify mutations are applied + EXPECT_EQ("yes", request_headers_.get_("x-cached-header")); + EXPECT_EQ(1U, config_->stats().ok_.value()); +} + +TEST_F(ExtAuthzCacheTest, CacheHitDenied) { + initialize(R"( + grpc_service: + envoy_grpc: + cluster_name: "ext_authz_server" + check_response_metadata_key: "authz_cache" + )"); + + prepareCheck(); + + // Prepare cached response + envoy::service::auth::v3::CheckResponse cached_response; + cached_response.mutable_status()->set_code(Grpc::Status::WellKnownGrpcStatus::PermissionDenied); + auto* denied_response = cached_response.mutable_denied_response(); + denied_response->mutable_status()->set_code(static_cast(enumToInt(Http::Code::Forbidden))); + denied_response->set_body("Access Denied by Cache"); + + setCacheMetadata(cached_response, "authz_cache"); + + // We expect client_->check to NOT be called + EXPECT_CALL(*client_, check(_, _, _, _)).Times(0); + + // Expect local reply + EXPECT_CALL(decoder_filter_callbacks_, sendLocalReply(Http::Code::Forbidden, "Access Denied by Cache", _, _, _)); + + // Call decodeHeaders + request_headers_.addCopy(Http::Headers::get().Host, "example.com"); + request_headers_.addCopy(Http::Headers::get().Method, "GET"); + request_headers_.addCopy(Http::Headers::get().Path, "/"); + + EXPECT_EQ(Http::FilterHeadersStatus::StopAllIterationAndWatermark, filter_->decodeHeaders(request_headers_, false)); + EXPECT_EQ(1U, config_->stats().denied_.value()); +} + +TEST_F(ExtAuthzCacheTest, CacheMissAndRecordgRPC) { + initialize(R"( + grpc_service: + envoy_grpc: + cluster_name: "ext_authz_server" + check_response_metadata_key: "authz_cache" + )"); + + prepareCheck(); + + // We expect client_->check to be called + Filters::Common::ExtAuthz::ResponsePtr authz_response = std::make_unique(); + authz_response->status = Filters::Common::ExtAuthz::CheckStatus::OK; + + envoy::service::auth::v3::CheckResponse raw_response; + raw_response.mutable_status()->set_code(Grpc::Status::WellKnownGrpcStatus::Ok); + auto* h = raw_response.mutable_ok_response()->add_headers(); + h->mutable_header()->set_key("x-live-header"); + h->mutable_header()->set_value("live"); + authz_response->raw_check_response = raw_response; + + EXPECT_CALL(*client_, check(_, _, _, _)) + .WillOnce(Invoke([&](Filters::Common::ExtAuthz::RequestCallbacks& callbacks, + const envoy::service::auth::v3::CheckRequest&, Tracing::Span&, + const StreamInfo::StreamInfo&) -> void { + callbacks.onComplete(std::move(authz_response)); + })); + + // Expect dynamic metadata to be set with the cached response + EXPECT_CALL(decoder_filter_callbacks_.stream_info_, setDynamicMetadata("envoy.filters.http.ext_authz", _)) + .WillOnce(Invoke([&](const std::string&, const Protobuf::Struct& metadata) { + auto it = metadata.fields().find("authz_cache"); + ASSERT_NE(it, metadata.fields().end()); + std::string decoded; + ASSERT_TRUE(absl::Base64Unescape(it->second.string_value(), &decoded)); + envoy::service::auth::v3::CheckResponse recorded; + ASSERT_TRUE(recorded.ParseFromString(decoded)); + EXPECT_EQ(Grpc::Status::WellKnownGrpcStatus::Ok, recorded.status().code()); + EXPECT_EQ("x-live-header", recorded.ok_response().headers(0).header().key()); + EXPECT_EQ("live", recorded.ok_response().headers(0).header().value()); + })); + + // Call decodeHeaders + request_headers_.addCopy(Http::Headers::get().Host, "example.com"); + request_headers_.addCopy(Http::Headers::get().Method, "GET"); + request_headers_.addCopy(Http::Headers::get().Path, "/"); + + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers_, false)); +} + +TEST_F(ExtAuthzCacheTest, InvalidCacheMetadataFallback) { + initialize(R"( + grpc_service: + envoy_grpc: + cluster_name: "ext_authz_server" + check_response_metadata_key: "authz_cache" + )"); + + prepareCheck(); + + // Set invalid Base64 in metadata + Protobuf::Struct struct_value; + (*struct_value.mutable_fields())["authz_cache"].set_string_value("invalid-base64-!!!"); + decoder_callbacks_metadata_.mutable_filter_metadata()->insert( + {"envoy.filters.http.ext_authz", struct_value}); + ON_CALL(decoder_filter_callbacks_.stream_info_, dynamicMetadata()) + .WillByDefault(ReturnRef(decoder_callbacks_metadata_)); + + // We expect fallback to live call, so client_->check IS called + 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::ResponsePtr fallback_response = std::make_unique(); + fallback_response->status = Filters::Common::ExtAuthz::CheckStatus::OK; + callbacks.onComplete(std::move(fallback_response)); + })); + + // Call decodeHeaders + request_headers_.addCopy(Http::Headers::get().Host, "example.com"); + request_headers_.addCopy(Http::Headers::get().Method, "GET"); + request_headers_.addCopy(Http::Headers::get().Path, "/"); + + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers_, false)); + + // Verify stats + EXPECT_EQ(1U, config_->stats().invalid_cached_response_.value()); +} + +} // namespace +} // namespace ExtAuthz +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy From d184a24859b7b5d77c6fa6960debf1b11b6e0c03 Mon Sep 17 00:00:00 2001 From: Todd Greer Date: Wed, 6 May 2026 04:53:39 +0000 Subject: [PATCH 02/10] ext_authz: refactor http client to avoid explicit variables Avoids creating temporary variables raw_check_response and error_response in RawHttpClientImpl::toResponse. Cleaned up trailing whitespace in common BUILD. Signed-off-by: Todd Greer --- .../extensions/filters/common/ext_authz/BUILD | 2 -- .../common/ext_authz/ext_authz_http_impl.cc | 25 ++++++++----------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/source/extensions/filters/common/ext_authz/BUILD b/source/extensions/filters/common/ext_authz/BUILD index c76593d5f8143..fe5d536b437ff 100644 --- a/source/extensions/filters/common/ext_authz/BUILD +++ b/source/extensions/filters/common/ext_authz/BUILD @@ -85,5 +85,3 @@ envoy_cc_library( "@envoy_api//envoy/service/auth/v3:pkg_cc_proto", ], ) - - 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 1894c52a4d0ef..4c73ca54c9dba 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 @@ -428,11 +428,10 @@ ResponsePtr RawHttpClientImpl::toResponse(Http::ResponseMessagePtr message) { // failure_mode_allow. if (Http::CodeUtility::is5xx(status_code)) { auto response = std::make_unique(errorResponse()); - envoy::service::auth::v3::CheckResponse raw_check_response; - raw_check_response.mutable_status()->set_code(Grpc::Status::WellKnownGrpcStatus::Internal); - auto* error_response = raw_check_response.mutable_error_response(); - error_response->mutable_status()->set_code(static_cast(status_code)); - response->raw_check_response = raw_check_response; + response->raw_check_response.emplace(); + response->raw_check_response->mutable_status()->set_code(Grpc::Status::WellKnownGrpcStatus::Internal); + response->raw_check_response->mutable_error_response()->mutable_status()->set_code( + static_cast(status_code)); return response; } @@ -484,9 +483,9 @@ ResponsePtr RawHttpClientImpl::toResponse(Http::ResponseMessagePtr message) { Http::Code::OK, Protobuf::Struct{}}}; - envoy::service::auth::v3::CheckResponse raw_check_response; - raw_check_response.mutable_status()->set_code(Grpc::Status::WellKnownGrpcStatus::Ok); - auto* ok_response = raw_check_response.mutable_ok_response(); + ok.response_->raw_check_response.emplace(); + ok.response_->raw_check_response->mutable_status()->set_code(Grpc::Status::WellKnownGrpcStatus::Ok); + auto* ok_response = ok.response_->raw_check_response->mutable_ok_response(); for (const auto& header : ok.response_->headers_to_set) { auto* h = ok_response->add_headers(); @@ -513,7 +512,6 @@ ResponsePtr RawHttpClientImpl::toResponse(Http::ResponseMessagePtr message) { *ok_response->mutable_dynamic_metadata() = ok.response_->dynamic_metadata; } - ok.response_->raw_check_response = raw_check_response; return std::move(ok.response_); } @@ -542,13 +540,13 @@ ResponsePtr RawHttpClientImpl::toResponse(Http::ResponseMessagePtr message) { static_cast(status_code), Protobuf::Struct{}}}; - envoy::service::auth::v3::CheckResponse raw_check_response; + denied.response_->raw_check_response.emplace(); const auto grpc_status = (status_code == enumToInt(Http::Code::Unauthorized)) ? Grpc::Status::WellKnownGrpcStatus::Unauthenticated : Grpc::Status::WellKnownGrpcStatus::PermissionDenied; - raw_check_response.mutable_status()->set_code(grpc_status); + denied.response_->raw_check_response->mutable_status()->set_code(grpc_status); - auto* denied_response = raw_check_response.mutable_denied_response(); + auto* denied_response = denied.response_->raw_check_response->mutable_denied_response(); denied_response->mutable_status()->set_code(static_cast(status_code)); denied_response->set_body(denied.response_->body); @@ -566,10 +564,9 @@ ResponsePtr RawHttpClientImpl::toResponse(Http::ResponseMessagePtr message) { } if (!denied.response_->dynamic_metadata.fields().empty()) { - *raw_check_response.mutable_dynamic_metadata() = denied.response_->dynamic_metadata; + *denied.response_->raw_check_response->mutable_dynamic_metadata() = denied.response_->dynamic_metadata; } - denied.response_->raw_check_response = raw_check_response; return std::move(denied.response_); } From c9b1ae7b691e38a559e56c4f867ba95a70ffae10 Mon Sep 17 00:00:00 2001 From: Todd Greer Date: Wed, 6 May 2026 08:16:31 +0000 Subject: [PATCH 03/10] ext_authz: apply review comments and add cache error tests Inlined metadata variable, cleaned up make_unique Response construction, and added ASSERT to Denied fallback in L7 filter. Refactored cache test to use compound namespaces and removed empty constructor. Added CacheHitErrorFailClosed and CacheHitErrorFailOpen tests. Signed-off-by: Todd Greer --- .../filters/http/ext_authz/ext_authz.cc | 7 +- .../http/ext_authz/ext_authz_cache_test.cc | 82 +++++++++++++++++-- 2 files changed, 76 insertions(+), 13 deletions(-) diff --git a/source/extensions/filters/http/ext_authz/ext_authz.cc b/source/extensions/filters/http/ext_authz/ext_authz.cc index 9528d6e7f870d..c5fbc03cf1c96 100644 --- a/source/extensions/filters/http/ext_authz/ext_authz.cc +++ b/source/extensions/filters/http/ext_authz/ext_authz.cc @@ -468,8 +468,7 @@ bool Filter::tryCacheHit() { return false; } - const auto& metadata = decoder_callbacks_->streamInfo().dynamicMetadata(); - const auto& filter_metadata = metadata.filter_metadata(); + const auto& filter_metadata = decoder_callbacks_->streamInfo().dynamicMetadata().filter_metadata(); const auto metadata_it = filter_metadata.find("envoy.filters.http.ext_authz"); if (metadata_it == filter_metadata.end()) { return false; @@ -501,8 +500,7 @@ bool Filter::tryCacheHit() { *decoder_callbacks_, metadata_key); Filters::Common::ExtAuthz::ResponsePtr authz_response = - std::make_unique( - Filters::Common::ExtAuthz::Response{}); + std::make_unique(); authz_response->grpc_status = check_response.status().code(); authz_response->raw_check_response = check_response; @@ -521,6 +519,7 @@ bool Filter::tryCacheHit() { } authz_response->body = error_response.body(); } else { + ASSERT(check_response.has_denied_response()); authz_response->status = Filters::Common::ExtAuthz::CheckStatus::Denied; authz_response->status_code = Http::Code::Forbidden; if (check_response.has_denied_response()) { diff --git a/test/extensions/filters/http/ext_authz/ext_authz_cache_test.cc b/test/extensions/filters/http/ext_authz/ext_authz_cache_test.cc index 67c4155e89ace..dd64f7c0e1265 100644 --- a/test/extensions/filters/http/ext_authz/ext_authz_cache_test.cc +++ b/test/extensions/filters/http/ext_authz/ext_authz_cache_test.cc @@ -35,15 +35,11 @@ using testing::NiceMock; using testing::Return; using testing::ReturnRef; -namespace Envoy { -namespace Extensions { -namespace HttpFilters { -namespace ExtAuthz { +namespace Envoy::Extensions::HttpFilters::ExtAuthz { namespace { class ExtAuthzCacheTest : public testing::Test { public: - ExtAuthzCacheTest() {} void initialize(const std::string& yaml) { envoy::extensions::filters::http::ext_authz::v3::ExtAuthz proto_config{}; @@ -255,8 +251,76 @@ TEST_F(ExtAuthzCacheTest, InvalidCacheMetadataFallback) { EXPECT_EQ(1U, config_->stats().invalid_cached_response_.value()); } +TEST_F(ExtAuthzCacheTest, CacheHitErrorFailClosed) { + initialize(R"( + grpc_service: + envoy_grpc: + cluster_name: "ext_authz_server" + check_response_metadata_key: "authz_cache" + )"); + + prepareCheck(); + + // Prepare cached response representing an error (500) + envoy::service::auth::v3::CheckResponse cached_response; + cached_response.mutable_status()->set_code(Grpc::Status::WellKnownGrpcStatus::Internal); + auto* error_response = cached_response.mutable_error_response(); + error_response->mutable_status()->set_code(static_cast(enumToInt(Http::Code::InternalServerError))); + error_response->set_body("Cached Error Body"); + + setCacheMetadata(cached_response, "authz_cache"); + + // We expect client_->check to NOT be called + EXPECT_CALL(*client_, check(_, _, _, _)).Times(0); + + // Expect local reply (fail-closed) with custom status from error_response + EXPECT_CALL(decoder_filter_callbacks_, sendLocalReply(Http::Code::InternalServerError, "Cached Error Body", _, _, _)); + + // Call decodeHeaders + request_headers_.addCopy(Http::Headers::get().Host, "example.com"); + request_headers_.addCopy(Http::Headers::get().Method, "GET"); + request_headers_.addCopy(Http::Headers::get().Path, "/"); + + EXPECT_EQ(Http::FilterHeadersStatus::StopAllIterationAndWatermark, filter_->decodeHeaders(request_headers_, false)); + EXPECT_EQ(1U, config_->stats().error_.value()); +} + +TEST_F(ExtAuthzCacheTest, CacheHitErrorFailOpen) { + initialize(R"( + grpc_service: + envoy_grpc: + cluster_name: "ext_authz_server" + check_response_metadata_key: "authz_cache" + failure_mode_allow: true + failure_mode_allow_header_add: true + )"); + + prepareCheck(); + + // Prepare cached response representing an error (500) + envoy::service::auth::v3::CheckResponse cached_response; + cached_response.mutable_status()->set_code(Grpc::Status::WellKnownGrpcStatus::Internal); + auto* error_response = cached_response.mutable_error_response(); + error_response->mutable_status()->set_code(static_cast(enumToInt(Http::Code::InternalServerError))); + + setCacheMetadata(cached_response, "authz_cache"); + + // We expect client_->check to NOT be called + EXPECT_CALL(*client_, check(_, _, _, _)).Times(0); + + // Call decodeHeaders + request_headers_.addCopy(Http::Headers::get().Host, "example.com"); + request_headers_.addCopy(Http::Headers::get().Method, "GET"); + request_headers_.addCopy(Http::Headers::get().Path, "/"); + + // Should continue decoding (fail-open) + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers_, false)); + + // Verify fail-open header is added + EXPECT_EQ("true", request_headers_.get_("x-envoy-auth-failure-mode-allowed")); + EXPECT_EQ(1U, config_->stats().error_.value()); + EXPECT_EQ(1U, config_->stats().failure_mode_allowed_.value()); +} + } // namespace -} // namespace ExtAuthz -} // namespace HttpFilters -} // namespace Extensions -} // namespace Envoy +} // namespace Envoy::Extensions::HttpFilters::ExtAuthz From 8fbf7ec7217613613b2ad5b1ca2a8aa48b42bee8 Mon Sep 17 00:00:00 2001 From: Todd Greer Date: Wed, 6 May 2026 09:40:52 +0000 Subject: [PATCH 04/10] ext_authz: document cooperative caching bypass Added Cooperative Caching Bypass section to ext_authz_filter.rst describing metadata-based bypass, misses, and fallback. Added invalid_cached_response to statistics table. Addressed review comments regarding CONE mention and redundant security considerations. Signed-off-by: Todd Greer --- .../http/http_filters/ext_authz_filter.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/root/configuration/http/http_filters/ext_authz_filter.rst b/docs/root/configuration/http/http_filters/ext_authz_filter.rst index be3765f790c75..e3470d89c78f8 100644 --- a/docs/root/configuration/http/http_filters/ext_authz_filter.rst +++ b/docs/root/configuration/http/http_filters/ext_authz_filter.rst @@ -176,6 +176,19 @@ In this configuration: This pattern provides clean separation between the decision logic (in the Lua filter) and the authorization enforcement (in ext_authz), while ensuring the ext_authz filter is only instantiated and invoked when needed. +Cooperative Caching Bypass +-------------------------- +The External Authorization filter supports bypassing the external authorization service call by retrieving a cached response from dynamic metadata. This is designed to work cooperatively with a preceding caching filter (such as a suitably configured ext_proc filter using an external cache). + +To enable this, configure the :ref:`check_response_metadata_key ` with the dynamic metadata key under the ``envoy.filters.http.ext_authz`` namespace where the cached response is stored. + +When a cached response is present under the configured key: +1. The filter will Base64-decode and deserialize it into a ``CheckResponse`` proto. +2. If deserialization succeeds, the filter will bypass the external service call and apply the cached response (OK, Denied, or Error) directly. +3. If deserialization fails, the filter will increment the ``invalid_cached_response`` stat and gracefully fall back to making a live call to the external authorization service. + +On cache misses (when no cached response is found), the filter will proceed with the live call and, upon receiving a successful response, will write the serialized ``CheckResponse`` as a Base64-encoded string back to the dynamic metadata under the configured key, allowing the caching filter to record and cache it. + Statistics ---------- .. _config_http_filters_ext_authz_stats: @@ -199,6 +212,7 @@ The HTTP filter outputs statistics in the ``cluster..ext_a because it couldn't apply all header mutations" response_header_limits_reached, Counter, "Total responses for which ext_authz sent a local reply because it couldn't apply all header mutations" + invalid_cached_response, Counter, Total cached responses that failed to be Base64-decoded or parsed. Dynamic Metadata ---------------- From 66ee693e864d4daa9231a23900cba937428a8da8 Mon Sep 17 00:00:00 2001 From: Todd Greer Date: Wed, 6 May 2026 09:59:01 +0000 Subject: [PATCH 05/10] ext_authz: add cooperative caching release note Added release note to changelogs/current.yaml under new_features describing ext_authz cooperative caching bypass and the new invalid_cached_response statistic. Refined wording to refer to it as external authorization HTTP filter. Signed-off-by: Todd Greer --- changelogs/current.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/changelogs/current.yaml b/changelogs/current.yaml index 9af0fadabfff4..c05901cdcc7e9 100644 --- a/changelogs/current.yaml +++ b/changelogs/current.yaml @@ -64,5 +64,14 @@ new_features: RSA public key exchange on behalf of the client. Added a new :ref:`downstream_ssl ` config option with ``DISABLE``, ``REQUIRE``, and ``ALLOW`` modes. +- area: ext_authz + change: | + Added cooperative caching bypass support to the external authorization HTTP filter. The filter can + now retrieve a cached ``CheckResponse`` from dynamic metadata, Base64-decode and deserialize it, and + apply the cached response directly, bypassing the live authorization service call. If deserialization + fails, the filter increments the new ``invalid_cached_response`` statistic and gracefully falls back + to a live network call. Configured via the new + :ref:`check_response_metadata_key ` + option. deprecated: From df2d6041d295e397c19802ee29ad6fd48c952a38 Mon Sep 17 00:00:00 2001 From: Todd Greer Date: Wed, 6 May 2026 22:20:44 +0000 Subject: [PATCH 06/10] ext_authz: add cache integration tests Created ext_authz_cache_integration_test.cc with OK and Denied cache hit integration tests. Simulates the caching filter using header_to_metadata to dynamically inject Base64 CheckResponse. Verifies end-to-end gRPC bypass and header mutation/local reply logic in a real Envoy process. Registered in BUILD. Signed-off-by: Todd Greer --- test/extensions/filters/http/ext_authz/BUILD | 19 +++ .../ext_authz_cache_integration_test.cc | 159 ++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 test/extensions/filters/http/ext_authz/ext_authz_cache_integration_test.cc diff --git a/test/extensions/filters/http/ext_authz/BUILD b/test/extensions/filters/http/ext_authz/BUILD index 0b4b77f10760c..b910f52be62dd 100644 --- a/test/extensions/filters/http/ext_authz/BUILD +++ b/test/extensions/filters/http/ext_authz/BUILD @@ -128,6 +128,25 @@ envoy_extension_cc_test( ], ) +envoy_extension_cc_test( + name = "ext_authz_cache_integration_test", + srcs = ["ext_authz_cache_integration_test.cc"], + extension_names = ["envoy.filters.http.ext_authz"], + rbe_pool = "linux_x64_small", + deps = [ + "//source/extensions/filters/http/ext_authz:config", + "//source/extensions/filters/http/header_to_metadata:config", + "//test/integration:http_integration_lib", + "//test/test_common:utility_lib", + "@envoy_api//envoy/config/bootstrap/v3:pkg_cc_proto", + "@envoy_api//envoy/config/core/v3:pkg_cc_proto", + "@envoy_api//envoy/extensions/filters/http/ext_authz/v3:pkg_cc_proto", + "@envoy_api//envoy/extensions/filters/network/http_connection_manager/v3:pkg_cc_proto", + "@envoy_api//envoy/service/auth/v3:pkg_cc_proto", + "@envoy_api//envoy/extensions/filters/http/header_to_metadata/v3:pkg_cc_proto", + ], +) + envoy_proto_library( name = "ext_authz_fuzz_proto", srcs = ["ext_authz_fuzz.proto"], diff --git a/test/extensions/filters/http/ext_authz/ext_authz_cache_integration_test.cc b/test/extensions/filters/http/ext_authz/ext_authz_cache_integration_test.cc new file mode 100644 index 0000000000000..fe3604821e93e --- /dev/null +++ b/test/extensions/filters/http/ext_authz/ext_authz_cache_integration_test.cc @@ -0,0 +1,159 @@ +#include "envoy/config/bootstrap/v3/bootstrap.pb.h" +#include "envoy/config/core/v3/base.pb.h" +#include "envoy/extensions/filters/http/ext_authz/v3/ext_authz.pb.h" +#include "envoy/extensions/filters/http/header_to_metadata/v3/header_to_metadata.pb.h" +#include "envoy/extensions/filters/network/http_connection_manager/v3/http_connection_manager.pb.h" +#include "envoy/service/auth/v3/external_auth.pb.h" + +#include "source/common/buffer/buffer_impl.h" +#include "source/common/common/base64.h" +#include "source/common/common/enum_to_int.h" +#include "source/common/protobuf/utility.h" + +#include "test/integration/http_integration.h" +#include "test/test_common/utility.h" + +#include "gtest/gtest.h" + +namespace Envoy { + +class ExtAuthzCacheIntegrationTest : public HttpIntegrationTest, + public testing::Test { +public: + ExtAuthzCacheIntegrationTest() + : HttpIntegrationTest(Http::CodecType::HTTP1, Network::Address::IpVersion::v4) {} + + void createUpstreams() override { + HttpIntegrationTest::createUpstreams(); + // Create fake upstream for ext_authz gRPC service + addFakeUpstream(Http::CodecType::HTTP2); + } + + void initializeConfig() { + config_helper_.addConfigModifier([this](envoy::config::bootstrap::v3::Bootstrap& bootstrap) { + // Add ext_authz cluster + auto* ext_authz_cluster = bootstrap.mutable_static_resources()->add_clusters(); + ext_authz_cluster->MergeFrom(bootstrap.static_resources().clusters()[0]); + ext_authz_cluster->set_name("ext_authz_cluster"); + ConfigHelper::setHttp2(*ext_authz_cluster); + + // 1. Setup header_to_metadata filter config (Cache Simulator) + envoy::extensions::filters::http::header_to_metadata::v3::Config h2m_proto; + const std::string h2m_yaml = R"YAML( + request_rules: + - header: x-simulate-cache + on_header_present: + metadata_namespace: envoy.filters.http.ext_authz + key: authz_cache + type: STRING + remove: true + )YAML"; + TestUtility::loadFromYaml(h2m_yaml, h2m_proto); + + envoy::config::core::v3::TypedExtensionConfig header_to_metadata_filter; + header_to_metadata_filter.set_name("envoy.filters.http.header_to_metadata"); + header_to_metadata_filter.mutable_typed_config()->PackFrom(h2m_proto); + + // 2. Setup ext_authz filter config + envoy::extensions::filters::http::ext_authz::v3::ExtAuthz ext_authz_proto; + ext_authz_proto.mutable_grpc_service()->mutable_envoy_grpc()->set_cluster_name("ext_authz_cluster"); + ext_authz_proto.set_check_response_metadata_key("authz_cache"); + ext_authz_proto.set_transport_api_version(envoy::config::core::v3::ApiVersion::V3); + + envoy::extensions::filters::network::http_connection_manager::v3::HttpFilter ext_authz_filter; + ext_authz_filter.set_name("envoy.filters.http.ext_authz"); + ext_authz_filter.mutable_typed_config()->PackFrom(ext_authz_proto); + + // Prepend filters to HCM (header_to_metadata first, then ext_authz) + config_helper_.prependFilter(MessageUtil::getJsonStringFromMessageOrError(ext_authz_filter)); + config_helper_.prependFilter(MessageUtil::getJsonStringFromMessageOrError(header_to_metadata_filter)); + }); + } + + std::string serializeAndEncode(const envoy::service::auth::v3::CheckResponse& response) { + std::string serialized; + RELEASE_ASSERT(response.SerializeToString(&serialized), "Failed to serialize CheckResponse"); + return Base64::encode(serialized.data(), serialized.size()); + } +}; + +TEST_F(ExtAuthzCacheIntegrationTest, CacheHitOKBypassesRPC) { + initializeConfig(); + HttpIntegrationTest::initialize(); + + // 1. Prepare cached OK response with header mutations + envoy::service::auth::v3::CheckResponse cached_response; + cached_response.mutable_status()->set_code(Grpc::Status::WellKnownGrpcStatus::Ok); + auto* header_to_add = cached_response.mutable_ok_response()->add_headers(); + header_to_add->mutable_header()->set_key("x-cached-header"); + header_to_add->mutable_header()->set_value("cache-value-ok"); + + std::string base64_cached_response = serializeAndEncode(cached_response); + + // 2. Client connection and request + codec_client_ = makeHttpConnection(makeClientConnection(lookupPort("http"))); + Http::TestRequestHeaderMapImpl headers{ + {":method", "GET"}, + {":path", "/test"}, + {":scheme", "http"}, + {":authority", "host"}, + {"x-simulate-cache", base64_cached_response} + }; + + auto response = codec_client_->makeHeaderOnlyRequest(headers); + + // 3. Verify request goes upstream with injected headers, and gRPC bypasses + AssertionResult result = fake_upstreams_[0]->waitForHttpConnection(*dispatcher_, fake_upstream_connection_); + RELEASE_ASSERT(result, result.message()); + result = fake_upstream_connection_->waitForNewStream(*dispatcher_, upstream_request_); + RELEASE_ASSERT(result, result.message()); + result = upstream_request_->waitForEndStream(*dispatcher_); + RELEASE_ASSERT(result, result.message()); + + // Verify mutated header is present upstream + EXPECT_THAT(upstream_request_->headers(), ContainsHeader("x-cached-header", "cache-value-ok")); + // Verify simulation header was stripped + EXPECT_TRUE(upstream_request_->headers().get(Http::LowerCaseString("x-simulate-cache")).empty()); + + // Send response from upstream + upstream_request_->encodeHeaders(Http::TestResponseHeaderMapImpl{{":status", "200"}}, true); + + // Client receives response + ASSERT_TRUE(response->waitForEndStream()); + EXPECT_TRUE(response->complete()); + EXPECT_EQ("200", response->headers().getStatusValue()); +} + +TEST_F(ExtAuthzCacheIntegrationTest, CacheHitDeniedBypassesRPC) { + initializeConfig(); + HttpIntegrationTest::initialize(); + + // 1. Prepare cached Denied response (403 Forbidden) + envoy::service::auth::v3::CheckResponse cached_response; + cached_response.mutable_status()->set_code(Grpc::Status::WellKnownGrpcStatus::PermissionDenied); + auto* denied_response = cached_response.mutable_denied_response(); + denied_response->mutable_status()->set_code(static_cast(enumToInt(Http::Code::Forbidden))); + denied_response->set_body("Cache Denied Body"); + + std::string base64_cached_response = serializeAndEncode(cached_response); + + // 2. Client request + codec_client_ = makeHttpConnection(makeClientConnection(lookupPort("http"))); + Http::TestRequestHeaderMapImpl headers{ + {":method", "GET"}, + {":path", "/test"}, + {":scheme", "http"}, + {":authority", "host"}, + {"x-simulate-cache", base64_cached_response} + }; + + auto response = codec_client_->makeHeaderOnlyRequest(headers); + + // 3. Verify client receives 403 local reply immediately, and upstream never sees request + ASSERT_TRUE(response->waitForEndStream()); + EXPECT_TRUE(response->complete()); + EXPECT_EQ("403", response->headers().getStatusValue()); + EXPECT_EQ("Cache Denied Body", response->body()); +} + +} // namespace Envoy From 1dd885dc6c3755a5944e5a4d6b7f2302fb0044a3 Mon Sep 17 00:00:00 2001 From: Todd Greer Date: Wed, 6 May 2026 23:20:00 +0000 Subject: [PATCH 07/10] ext_authz: refactor cache integration tests based on reviews Refactored ext_authz_cache_integration_test.cc incorporating your reviews: switched downstream to HTTP2, renamed initializeConfig() to initialize() override, removed redundant comments, and renamed the metadata key to cached_authz_response. Kept createUpstreams() to allocate secondary dynamic ports. Signed-off-by: Todd Greer --- .../ext_authz_cache_integration_test.cc | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/test/extensions/filters/http/ext_authz/ext_authz_cache_integration_test.cc b/test/extensions/filters/http/ext_authz/ext_authz_cache_integration_test.cc index fe3604821e93e..19b790c277f04 100644 --- a/test/extensions/filters/http/ext_authz/ext_authz_cache_integration_test.cc +++ b/test/extensions/filters/http/ext_authz/ext_authz_cache_integration_test.cc @@ -21,30 +21,28 @@ class ExtAuthzCacheIntegrationTest : public HttpIntegrationTest, public testing::Test { public: ExtAuthzCacheIntegrationTest() - : HttpIntegrationTest(Http::CodecType::HTTP1, Network::Address::IpVersion::v4) {} + : HttpIntegrationTest(Http::CodecType::HTTP2, Network::Address::IpVersion::v4) {} void createUpstreams() override { HttpIntegrationTest::createUpstreams(); - // Create fake upstream for ext_authz gRPC service addFakeUpstream(Http::CodecType::HTTP2); } - void initializeConfig() { + void initialize() override { config_helper_.addConfigModifier([this](envoy::config::bootstrap::v3::Bootstrap& bootstrap) { - // Add ext_authz cluster auto* ext_authz_cluster = bootstrap.mutable_static_resources()->add_clusters(); ext_authz_cluster->MergeFrom(bootstrap.static_resources().clusters()[0]); ext_authz_cluster->set_name("ext_authz_cluster"); ConfigHelper::setHttp2(*ext_authz_cluster); - // 1. Setup header_to_metadata filter config (Cache Simulator) + // 1. Set up header_to_metadata filter config (Fake Cache) envoy::extensions::filters::http::header_to_metadata::v3::Config h2m_proto; const std::string h2m_yaml = R"YAML( request_rules: - header: x-simulate-cache on_header_present: metadata_namespace: envoy.filters.http.ext_authz - key: authz_cache + key: cached_authz_response type: STRING remove: true )YAML"; @@ -54,10 +52,10 @@ class ExtAuthzCacheIntegrationTest : public HttpIntegrationTest, header_to_metadata_filter.set_name("envoy.filters.http.header_to_metadata"); header_to_metadata_filter.mutable_typed_config()->PackFrom(h2m_proto); - // 2. Setup ext_authz filter config + // 2. Set up ext_authz filter config envoy::extensions::filters::http::ext_authz::v3::ExtAuthz ext_authz_proto; ext_authz_proto.mutable_grpc_service()->mutable_envoy_grpc()->set_cluster_name("ext_authz_cluster"); - ext_authz_proto.set_check_response_metadata_key("authz_cache"); + ext_authz_proto.set_check_response_metadata_key("cached_authz_response"); ext_authz_proto.set_transport_api_version(envoy::config::core::v3::ApiVersion::V3); envoy::extensions::filters::network::http_connection_manager::v3::HttpFilter ext_authz_filter; @@ -68,6 +66,8 @@ class ExtAuthzCacheIntegrationTest : public HttpIntegrationTest, config_helper_.prependFilter(MessageUtil::getJsonStringFromMessageOrError(ext_authz_filter)); config_helper_.prependFilter(MessageUtil::getJsonStringFromMessageOrError(header_to_metadata_filter)); }); + + HttpIntegrationTest::initialize(); } std::string serializeAndEncode(const envoy::service::auth::v3::CheckResponse& response) { @@ -78,8 +78,7 @@ class ExtAuthzCacheIntegrationTest : public HttpIntegrationTest, }; TEST_F(ExtAuthzCacheIntegrationTest, CacheHitOKBypassesRPC) { - initializeConfig(); - HttpIntegrationTest::initialize(); + initialize(); // 1. Prepare cached OK response with header mutations envoy::service::auth::v3::CheckResponse cached_response; @@ -125,8 +124,7 @@ TEST_F(ExtAuthzCacheIntegrationTest, CacheHitOKBypassesRPC) { } TEST_F(ExtAuthzCacheIntegrationTest, CacheHitDeniedBypassesRPC) { - initializeConfig(); - HttpIntegrationTest::initialize(); + initialize(); // 1. Prepare cached Denied response (403 Forbidden) envoy::service::auth::v3::CheckResponse cached_response; From 46fe75f0b6008de9925eb4c63dd168d5ac5a30cb Mon Sep 17 00:00:00 2001 From: Todd Greer Date: Thu, 7 May 2026 20:45:41 +0000 Subject: [PATCH 08/10] ext_authz: transition cache bypass to typed dynamic metadata Switched L7 HTTP external authorization caching bypass to strongly-typed dynamic metadata. Replaced check_response_metadata_key with check_response_typed_metadata_namespace in API and C++ config. Refactored tryCacheHit() and onComplete() to directly read and write CheckResponse Any payloads under the configured namespace. Updated unit and integration test suites. Signed-off-by: Todd Greer --- .../filters/http/ext_authz/v3/ext_authz.proto | 13 +-- .../filters/http/ext_authz/ext_authz.cc | 56 +++++-------- .../filters/http/ext_authz/ext_authz.h | 6 +- test/extensions/filters/http/ext_authz/BUILD | 4 +- .../ext_authz_cache_integration_test.cc | 84 ++++++++++++++----- .../http/ext_authz/ext_authz_cache_test.cc | 62 +++++++------- 6 files changed, 120 insertions(+), 105 deletions(-) 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 828a7f449f0f9..3e19681814bf8 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 @@ -387,15 +387,10 @@ message ExtAuthz { // Defaults to ``false``. bool shadow_mode = 32; - // [WARNING]: Users must ensure that the configured key does not conflict with - // keys returned by the authorization server itself in its dynamic metadata - // (either via the dynamic_metadata field in CheckResponse or headers mapped to - // dynamic metadata). A collision could allow an external server to overwrite - // or corrupt the cache, potentially leading to security bypasses or unexpected behavior. - // - // If, during ``decodeHeaders``, this dynamic metadata key contains a serialized CheckResponse, - // the filter will apply that CheckResponse as if it had been returned by the external service (which won't be called). - string check_response_metadata_key = 33; + // If set, enables bypassing the external authorization service call by retrieving + // a cached CheckResponse directly from dynamic typed metadata under this namespace. + // If this namespace is empty, the cooperative caching bypass feature is disabled. + string check_response_typed_metadata_namespace = 33; } // Serialized form of the shadow-mode authorization decision written to FilterState diff --git a/source/extensions/filters/http/ext_authz/ext_authz.cc b/source/extensions/filters/http/ext_authz/ext_authz.cc index c5fbc03cf1c96..b870344d43f90 100644 --- a/source/extensions/filters/http/ext_authz/ext_authz.cc +++ b/source/extensions/filters/http/ext_authz/ext_authz.cc @@ -200,7 +200,7 @@ FilterConfig::FilterConfig(const envoy::extensions::filters::http::ext_authz::v3 include_tls_session_(config.include_tls_session()), charge_cluster_response_stats_( PROTOBUF_GET_WRAPPED_OR_DEFAULT(config, charge_cluster_response_stats, true)), - check_response_metadata_key_(config.check_response_metadata_key()), + check_response_typed_metadata_namespace_(config.check_response_typed_metadata_namespace()), stats_(generateStats(stats_prefix, config.stat_prefix(), scope)), ext_authz_ok_(pool_.add(createPoolStatName(config.stat_prefix(), "ok"))), ext_authz_denied_(pool_.add(createPoolStatName(config.stat_prefix(), "denied"))), @@ -463,41 +463,28 @@ void Filter::initiateCall(const Http::RequestHeaderMap& headers) { } bool Filter::tryCacheHit() { - const std::string& metadata_key = config_->checkResponseMetadataKey(); - if (metadata_key.empty()) { + const std::string& metadata_namespace = config_->checkResponseTypedMetadataNamespace(); + if (metadata_namespace.empty()) { return false; } - const auto& filter_metadata = decoder_callbacks_->streamInfo().dynamicMetadata().filter_metadata(); - const auto metadata_it = filter_metadata.find("envoy.filters.http.ext_authz"); - if (metadata_it == filter_metadata.end()) { + const auto& typed_metadata = decoder_callbacks_->streamInfo().dynamicMetadata().typed_filter_metadata(); + const auto cache_it = typed_metadata.find(metadata_namespace); + if (cache_it == typed_metadata.end()) { return false; } - const auto& fields = metadata_it->second.fields(); - const auto field_it = fields.find(metadata_key); - if (field_it == fields.end()) { - return false; - } - - std::string unescaped; envoy::service::auth::v3::CheckResponse check_response; - if (!absl::Base64Unescape(field_it->second.string_value(), &unescaped)) { - ENVOY_STREAM_LOG(warn, "ext_authz failed to Base64 decode cached response in metadata key {}", - *decoder_callbacks_, metadata_key); + auto status = MessageUtil::unpackTo(cache_it->second, check_response); + if (!status.ok()) { + ENVOY_STREAM_LOG(warn, "ext_authz failed to unpack cached CheckResponse in namespace {}", + *decoder_callbacks_, metadata_namespace); stats_.invalid_cached_response_.inc(); return false; } - if (!check_response.ParseFromString(unescaped)) { - ENVOY_STREAM_LOG(warn, "ext_authz failed to parse cached CheckResponse in metadata key {}", - *decoder_callbacks_, metadata_key); - stats_.invalid_cached_response_.inc(); - return false; - } - - ENVOY_STREAM_LOG(debug, "ext_authz found cached CheckResponse in metadata key {}", - *decoder_callbacks_, metadata_key); + ENVOY_STREAM_LOG(debug, "ext_authz found cached CheckResponse in typed namespace {}", + *decoder_callbacks_, metadata_namespace); Filters::Common::ExtAuthz::ResponsePtr authz_response = std::make_unique(); @@ -812,18 +799,13 @@ void Filter::onComplete(Filters::Common::ExtAuthz::ResponsePtr&& response) { updateLoggingInfo(response->grpc_status); - const std::string& metadata_key = config_->checkResponseMetadataKey(); - if (!metadata_key.empty() && response->raw_check_response.has_value()) { - std::string serialized_response; - if (response->raw_check_response->SerializeToString(&serialized_response)) { - Protobuf::Struct struct_value; - (*struct_value.mutable_fields())[metadata_key].set_string_value( - absl::Base64Escape(serialized_response)); - decoder_callbacks_->streamInfo().setDynamicMetadata("envoy.filters.http.ext_authz", - struct_value); - ENVOY_STREAM_LOG(debug, "ext_authz stored CheckResponse in metadata key {}", - *decoder_callbacks_, metadata_key); - } + const std::string& metadata_namespace = config_->checkResponseTypedMetadataNamespace(); + if (!metadata_namespace.empty() && response->raw_check_response.has_value()) { + Protobuf::Any typed_metadata; + typed_metadata.PackFrom(response->raw_check_response.value()); + decoder_callbacks_->streamInfo().setDynamicTypedMetadata(metadata_namespace, typed_metadata); + ENVOY_STREAM_LOG(debug, "ext_authz stored CheckResponse in typed namespace {}", + *decoder_callbacks_, metadata_namespace); } applyResponse(std::move(response)); diff --git a/source/extensions/filters/http/ext_authz/ext_authz.h b/source/extensions/filters/http/ext_authz/ext_authz.h index 7417534366131..b75bb31127157 100644 --- a/source/extensions/filters/http/ext_authz/ext_authz.h +++ b/source/extensions/filters/http/ext_authz/ext_authz.h @@ -280,7 +280,9 @@ class FilterConfig { bool chargeClusterResponseStats() const { return charge_cluster_response_stats_; } - const std::string& checkResponseMetadataKey() const { return check_response_metadata_key_; } + const std::string& checkResponseTypedMetadataNamespace() const { + return check_response_typed_metadata_namespace_; + } const Filters::Common::ExtAuthz::MatcherSharedPtr& allowedHeadersMatcher() const { return allowed_headers_matcher_; @@ -351,7 +353,7 @@ class FilterConfig { const bool include_peer_certificate_; const bool include_tls_session_; const bool charge_cluster_response_stats_; - const std::string check_response_metadata_key_; + const std::string check_response_typed_metadata_namespace_; // The stats for the filter. ExtAuthzFilterStats stats_; diff --git a/test/extensions/filters/http/ext_authz/BUILD b/test/extensions/filters/http/ext_authz/BUILD index b910f52be62dd..7af6e75484633 100644 --- a/test/extensions/filters/http/ext_authz/BUILD +++ b/test/extensions/filters/http/ext_authz/BUILD @@ -135,7 +135,8 @@ envoy_extension_cc_test( rbe_pool = "linux_x64_small", deps = [ "//source/extensions/filters/http/ext_authz:config", - "//source/extensions/filters/http/header_to_metadata:config", + "//source/extensions/filters/http/common:pass_through_filter_lib", + "//test/extensions/filters/http/common:empty_http_filter_config_lib", "//test/integration:http_integration_lib", "//test/test_common:utility_lib", "@envoy_api//envoy/config/bootstrap/v3:pkg_cc_proto", @@ -143,7 +144,6 @@ envoy_extension_cc_test( "@envoy_api//envoy/extensions/filters/http/ext_authz/v3:pkg_cc_proto", "@envoy_api//envoy/extensions/filters/network/http_connection_manager/v3:pkg_cc_proto", "@envoy_api//envoy/service/auth/v3:pkg_cc_proto", - "@envoy_api//envoy/extensions/filters/http/header_to_metadata/v3:pkg_cc_proto", ], ) diff --git a/test/extensions/filters/http/ext_authz/ext_authz_cache_integration_test.cc b/test/extensions/filters/http/ext_authz/ext_authz_cache_integration_test.cc index 19b790c277f04..c735688367111 100644 --- a/test/extensions/filters/http/ext_authz/ext_authz_cache_integration_test.cc +++ b/test/extensions/filters/http/ext_authz/ext_authz_cache_integration_test.cc @@ -1,22 +1,72 @@ #include "envoy/config/bootstrap/v3/bootstrap.pb.h" #include "envoy/config/core/v3/base.pb.h" #include "envoy/extensions/filters/http/ext_authz/v3/ext_authz.pb.h" -#include "envoy/extensions/filters/http/header_to_metadata/v3/header_to_metadata.pb.h" #include "envoy/extensions/filters/network/http_connection_manager/v3/http_connection_manager.pb.h" #include "envoy/service/auth/v3/external_auth.pb.h" +#include "envoy/registry/registry.h" +#include "envoy/server/filter_config.h" #include "source/common/buffer/buffer_impl.h" #include "source/common/common/base64.h" #include "source/common/common/enum_to_int.h" #include "source/common/protobuf/utility.h" +#include "source/extensions/filters/http/common/pass_through_filter.h" #include "test/integration/http_integration.h" #include "test/test_common/utility.h" +#include "test/extensions/filters/http/common/empty_http_filter_config.h" #include "gtest/gtest.h" namespace Envoy { +// A simple test-only HTTP filter that acts as the "Fake Cache" caching layer in our integration test. +// It intercepts requests with 'x-simulate-cache' header, Base64-decodes and deserializes it into +// a strongly-typed CheckResponse proto, and writes it directly to dynamic typed metadata under the +// configured cache namespace before stripping the header. +class CacheSimulatorFilter : public Http::PassThroughFilter { +public: + CacheSimulatorFilter() = default; + + Http::FilterHeadersStatus decodeHeaders(Http::RequestHeaderMap& headers, bool) override { + const auto simulate_header = headers.get(Http::LowerCaseString("x-simulate-cache")); + if (!simulate_header.empty()) { + std::string base64_response = std::string(simulate_header[0]->value().getStringView()); + std::string decoded = Base64::decode(base64_response); + if (!decoded.empty()) { + envoy::service::auth::v3::CheckResponse check_response; + if (check_response.ParseFromString(decoded)) { + Protobuf::Any typed_metadata; + typed_metadata.PackFrom(check_response); + + // Store direct CheckResponse Any under the configured typed metadata cache namespace + decoder_callbacks_->streamInfo().setDynamicTypedMetadata( + "envoy.filters.http.ext_authz.cache", typed_metadata); + } + } + headers.remove(Http::LowerCaseString("x-simulate-cache")); + } + return Http::FilterHeadersStatus::Continue; + } +}; + +class CacheSimulatorFilterConfig : public Extensions::HttpFilters::Common::EmptyHttpDualFilterConfig { +public: + CacheSimulatorFilterConfig() : EmptyHttpDualFilterConfig("cache-simulator-filter") {} + absl::StatusOr + createDualFilter(const std::string&, Server::Configuration::ServerFactoryContext&) override { + return [](Http::FilterChainFactoryCallbacks& callbacks) -> void { + callbacks.addStreamFilter(std::make_shared()); + }; + } +}; + +// Perform static registration so Envoy's bootstrap configuration can resolve it +static Registry::RegisterFactory + register_cache_simulator_filter_; + + class ExtAuthzCacheIntegrationTest : public HttpIntegrationTest, public testing::Test { public: @@ -25,46 +75,36 @@ class ExtAuthzCacheIntegrationTest : public HttpIntegrationTest, void createUpstreams() override { HttpIntegrationTest::createUpstreams(); + // Allocate secondary dynamic ports to satisfy ConfigHelper configuration finalize addFakeUpstream(Http::CodecType::HTTP2); } void initialize() override { config_helper_.addConfigModifier([this](envoy::config::bootstrap::v3::Bootstrap& bootstrap) { + // Add ext_authz cluster, dynamic endpoint will be mapped automatically by ConfigHelper auto* ext_authz_cluster = bootstrap.mutable_static_resources()->add_clusters(); ext_authz_cluster->MergeFrom(bootstrap.static_resources().clusters()[0]); ext_authz_cluster->set_name("ext_authz_cluster"); ConfigHelper::setHttp2(*ext_authz_cluster); - // 1. Set up header_to_metadata filter config (Fake Cache) - envoy::extensions::filters::http::header_to_metadata::v3::Config h2m_proto; - const std::string h2m_yaml = R"YAML( - request_rules: - - header: x-simulate-cache - on_header_present: - metadata_namespace: envoy.filters.http.ext_authz - key: cached_authz_response - type: STRING - remove: true - )YAML"; - TestUtility::loadFromYaml(h2m_yaml, h2m_proto); - - envoy::config::core::v3::TypedExtensionConfig header_to_metadata_filter; - header_to_metadata_filter.set_name("envoy.filters.http.header_to_metadata"); - header_to_metadata_filter.mutable_typed_config()->PackFrom(h2m_proto); - - // 2. Set up ext_authz filter config + // 1. Set up CacheSimulatorFilter (Fake Cache) using empty config + envoy::config::core::v3::TypedExtensionConfig cache_simulator_config; + cache_simulator_config.set_name("cache-simulator-filter"); + cache_simulator_config.mutable_typed_config()->PackFrom(Protobuf::Struct()); + + // 2. Set up ext_authz filter config, bypass namespace set envoy::extensions::filters::http::ext_authz::v3::ExtAuthz ext_authz_proto; ext_authz_proto.mutable_grpc_service()->mutable_envoy_grpc()->set_cluster_name("ext_authz_cluster"); - ext_authz_proto.set_check_response_metadata_key("cached_authz_response"); + ext_authz_proto.set_check_response_typed_metadata_namespace("envoy.filters.http.ext_authz.cache"); ext_authz_proto.set_transport_api_version(envoy::config::core::v3::ApiVersion::V3); envoy::extensions::filters::network::http_connection_manager::v3::HttpFilter ext_authz_filter; ext_authz_filter.set_name("envoy.filters.http.ext_authz"); ext_authz_filter.mutable_typed_config()->PackFrom(ext_authz_proto); - // Prepend filters to HCM (header_to_metadata first, then ext_authz) + // Prepend filters to HCM (cache_simulator_filter first, then ext_authz) config_helper_.prependFilter(MessageUtil::getJsonStringFromMessageOrError(ext_authz_filter)); - config_helper_.prependFilter(MessageUtil::getJsonStringFromMessageOrError(header_to_metadata_filter)); + config_helper_.prependFilter(MessageUtil::getJsonStringFromMessageOrError(cache_simulator_config)); }); HttpIntegrationTest::initialize(); diff --git a/test/extensions/filters/http/ext_authz/ext_authz_cache_test.cc b/test/extensions/filters/http/ext_authz/ext_authz_cache_test.cc index dd64f7c0e1265..bfdbab384adf5 100644 --- a/test/extensions/filters/http/ext_authz/ext_authz_cache_test.cc +++ b/test/extensions/filters/http/ext_authz/ext_authz_cache_test.cc @@ -64,17 +64,13 @@ class ExtAuthzCacheTest : public testing::Test { connection_.stream_info_.downstream_connection_info_provider_->setLocalAddress(addr_); } - void setCacheMetadata(const envoy::service::auth::v3::CheckResponse& response, const std::string& key) { - std::string serialized; - ASSERT_TRUE(response.SerializeToString(&serialized)); - std::string encoded = absl::Base64Escape(serialized); - - Protobuf::Struct struct_value; - (*struct_value.mutable_fields())[key].set_string_value(encoded); + void setCacheMetadata(const envoy::service::auth::v3::CheckResponse& response, const std::string& metadata_namespace) { + Protobuf::Any typed_metadata; + typed_metadata.PackFrom(response); - // Set it in dynamic metadata - decoder_callbacks_metadata_.mutable_filter_metadata()->insert( - {"envoy.filters.http.ext_authz", struct_value}); + // Set it in dynamic typed metadata + decoder_callbacks_metadata_.mutable_typed_filter_metadata()->insert( + {metadata_namespace, typed_metadata}); ON_CALL(decoder_filter_callbacks_.stream_info_, dynamicMetadata()) .WillByDefault(ReturnRef(decoder_callbacks_metadata_)); @@ -98,7 +94,7 @@ TEST_F(ExtAuthzCacheTest, CacheHitOK) { grpc_service: envoy_grpc: cluster_name: "ext_authz_server" - check_response_metadata_key: "authz_cache" + check_response_typed_metadata_namespace: "envoy.filters.http.ext_authz.cache" )"); prepareCheck(); @@ -111,7 +107,7 @@ TEST_F(ExtAuthzCacheTest, CacheHitOK) { header->mutable_header()->set_key("x-cached-header"); header->mutable_header()->set_value("yes"); - setCacheMetadata(cached_response, "authz_cache"); + setCacheMetadata(cached_response, "envoy.filters.http.ext_authz.cache"); // We expect client_->check to NOT be called EXPECT_CALL(*client_, check(_, _, _, _)).Times(0); @@ -133,7 +129,7 @@ TEST_F(ExtAuthzCacheTest, CacheHitDenied) { grpc_service: envoy_grpc: cluster_name: "ext_authz_server" - check_response_metadata_key: "authz_cache" + check_response_typed_metadata_namespace: "envoy.filters.http.ext_authz.cache" )"); prepareCheck(); @@ -145,7 +141,7 @@ TEST_F(ExtAuthzCacheTest, CacheHitDenied) { denied_response->mutable_status()->set_code(static_cast(enumToInt(Http::Code::Forbidden))); denied_response->set_body("Access Denied by Cache"); - setCacheMetadata(cached_response, "authz_cache"); + setCacheMetadata(cached_response, "envoy.filters.http.ext_authz.cache"); // We expect client_->check to NOT be called EXPECT_CALL(*client_, check(_, _, _, _)).Times(0); @@ -167,7 +163,7 @@ TEST_F(ExtAuthzCacheTest, CacheMissAndRecordgRPC) { grpc_service: envoy_grpc: cluster_name: "ext_authz_server" - check_response_metadata_key: "authz_cache" + check_response_typed_metadata_namespace: "envoy.filters.http.ext_authz.cache" )"); prepareCheck(); @@ -190,15 +186,11 @@ TEST_F(ExtAuthzCacheTest, CacheMissAndRecordgRPC) { callbacks.onComplete(std::move(authz_response)); })); - // Expect dynamic metadata to be set with the cached response - EXPECT_CALL(decoder_filter_callbacks_.stream_info_, setDynamicMetadata("envoy.filters.http.ext_authz", _)) - .WillOnce(Invoke([&](const std::string&, const Protobuf::Struct& metadata) { - auto it = metadata.fields().find("authz_cache"); - ASSERT_NE(it, metadata.fields().end()); - std::string decoded; - ASSERT_TRUE(absl::Base64Unescape(it->second.string_value(), &decoded)); + // Expect dynamic typed metadata to be set with the cached response directly + EXPECT_CALL(decoder_filter_callbacks_.stream_info_, setDynamicTypedMetadata("envoy.filters.http.ext_authz.cache", _)) + .WillOnce(Invoke([&](const std::string&, const Protobuf::Any& metadata) { envoy::service::auth::v3::CheckResponse recorded; - ASSERT_TRUE(recorded.ParseFromString(decoded)); + ASSERT_TRUE(MessageUtil::unpackTo(metadata, recorded).ok()); EXPECT_EQ(Grpc::Status::WellKnownGrpcStatus::Ok, recorded.status().code()); EXPECT_EQ("x-live-header", recorded.ok_response().headers(0).header().key()); EXPECT_EQ("live", recorded.ok_response().headers(0).header().value()); @@ -217,16 +209,20 @@ TEST_F(ExtAuthzCacheTest, InvalidCacheMetadataFallback) { grpc_service: envoy_grpc: cluster_name: "ext_authz_server" - check_response_metadata_key: "authz_cache" + check_response_typed_metadata_namespace: "envoy.filters.http.ext_authz.cache" )"); prepareCheck(); - // Set invalid Base64 in metadata - Protobuf::Struct struct_value; - (*struct_value.mutable_fields())["authz_cache"].set_string_value("invalid-base64-!!!"); - decoder_callbacks_metadata_.mutable_filter_metadata()->insert( - {"envoy.filters.http.ext_authz", struct_value}); + // Set unexpected proto type in dynamic typed metadata to trigger unpack failure + envoy::config::core::v3::Metadata unexpected_proto; + (*unexpected_proto.mutable_filter_metadata())["unexpected"] = {}; + + Protobuf::Any typed_metadata_any; + typed_metadata_any.PackFrom(unexpected_proto); + + decoder_callbacks_metadata_.mutable_typed_filter_metadata()->insert( + {"envoy.filters.http.ext_authz.cache", typed_metadata_any}); ON_CALL(decoder_filter_callbacks_.stream_info_, dynamicMetadata()) .WillByDefault(ReturnRef(decoder_callbacks_metadata_)); @@ -256,7 +252,7 @@ TEST_F(ExtAuthzCacheTest, CacheHitErrorFailClosed) { grpc_service: envoy_grpc: cluster_name: "ext_authz_server" - check_response_metadata_key: "authz_cache" + check_response_typed_metadata_namespace: "envoy.filters.http.ext_authz.cache" )"); prepareCheck(); @@ -268,7 +264,7 @@ TEST_F(ExtAuthzCacheTest, CacheHitErrorFailClosed) { error_response->mutable_status()->set_code(static_cast(enumToInt(Http::Code::InternalServerError))); error_response->set_body("Cached Error Body"); - setCacheMetadata(cached_response, "authz_cache"); + setCacheMetadata(cached_response, "envoy.filters.http.ext_authz.cache"); // We expect client_->check to NOT be called EXPECT_CALL(*client_, check(_, _, _, _)).Times(0); @@ -290,7 +286,7 @@ TEST_F(ExtAuthzCacheTest, CacheHitErrorFailOpen) { grpc_service: envoy_grpc: cluster_name: "ext_authz_server" - check_response_metadata_key: "authz_cache" + check_response_typed_metadata_namespace: "envoy.filters.http.ext_authz.cache" failure_mode_allow: true failure_mode_allow_header_add: true )"); @@ -303,7 +299,7 @@ TEST_F(ExtAuthzCacheTest, CacheHitErrorFailOpen) { auto* error_response = cached_response.mutable_error_response(); error_response->mutable_status()->set_code(static_cast(enumToInt(Http::Code::InternalServerError))); - setCacheMetadata(cached_response, "authz_cache"); + setCacheMetadata(cached_response, "envoy.filters.http.ext_authz.cache"); // We expect client_->check to NOT be called EXPECT_CALL(*client_, check(_, _, _, _)).Times(0); From b77bfd61c5c207b161fd94e44ff173f9d3ff0f31 Mon Sep 17 00:00:00 2001 From: Todd Greer Date: Fri, 8 May 2026 18:54:18 +0000 Subject: [PATCH 09/10] ext_authz: apply code formatting and style fixes Ran the official check_format script to auto-format C++ style violations (clang-format, buildifier) across the modified files. Reverted the unit test ext_authz_cache_test.cc back to standard nested namespaces to satisfy the strict Envoy linter. Signed-off-by: Todd Greer --- .../common/ext_authz/ext_authz_http_impl.cc | 12 ++-- .../filters/http/ext_authz/ext_authz.cc | 13 ++-- .../ext_authz/ext_authz_grpc_impl_test.cc | 6 +- .../ext_authz/ext_authz_http_impl_test.cc | 24 +++---- test/extensions/filters/http/ext_authz/BUILD | 2 +- .../ext_authz_cache_integration_test.cc | 62 +++++++++---------- .../http/ext_authz/ext_authz_cache_test.cc | 58 ++++++++++------- 7 files changed, 99 insertions(+), 78 deletions(-) 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 4c73ca54c9dba..ebe084a3516e4 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 @@ -429,7 +429,8 @@ ResponsePtr RawHttpClientImpl::toResponse(Http::ResponseMessagePtr message) { if (Http::CodeUtility::is5xx(status_code)) { auto response = std::make_unique(errorResponse()); response->raw_check_response.emplace(); - response->raw_check_response->mutable_status()->set_code(Grpc::Status::WellKnownGrpcStatus::Internal); + response->raw_check_response->mutable_status()->set_code( + Grpc::Status::WellKnownGrpcStatus::Internal); response->raw_check_response->mutable_error_response()->mutable_status()->set_code( static_cast(status_code)); return response; @@ -484,7 +485,8 @@ ResponsePtr RawHttpClientImpl::toResponse(Http::ResponseMessagePtr message) { Protobuf::Struct{}}}; ok.response_->raw_check_response.emplace(); - ok.response_->raw_check_response->mutable_status()->set_code(Grpc::Status::WellKnownGrpcStatus::Ok); + ok.response_->raw_check_response->mutable_status()->set_code( + Grpc::Status::WellKnownGrpcStatus::Ok); auto* ok_response = ok.response_->raw_check_response->mutable_ok_response(); for (const auto& header : ok.response_->headers_to_set) { @@ -547,7 +549,8 @@ ResponsePtr RawHttpClientImpl::toResponse(Http::ResponseMessagePtr message) { denied.response_->raw_check_response->mutable_status()->set_code(grpc_status); auto* denied_response = denied.response_->raw_check_response->mutable_denied_response(); - denied_response->mutable_status()->set_code(static_cast(status_code)); + denied_response->mutable_status()->set_code( + static_cast(status_code)); denied_response->set_body(denied.response_->body); for (const auto& header : denied.response_->headers_to_set) { @@ -564,7 +567,8 @@ ResponsePtr RawHttpClientImpl::toResponse(Http::ResponseMessagePtr message) { } if (!denied.response_->dynamic_metadata.fields().empty()) { - *denied.response_->raw_check_response->mutable_dynamic_metadata() = denied.response_->dynamic_metadata; + *denied.response_->raw_check_response->mutable_dynamic_metadata() = + denied.response_->dynamic_metadata; } return std::move(denied.response_); diff --git a/source/extensions/filters/http/ext_authz/ext_authz.cc b/source/extensions/filters/http/ext_authz/ext_authz.cc index b870344d43f90..4c5aaffc3d3f1 100644 --- a/source/extensions/filters/http/ext_authz/ext_authz.cc +++ b/source/extensions/filters/http/ext_authz/ext_authz.cc @@ -5,8 +5,6 @@ #include #include -#include "absl/strings/escaping.h" - #include "envoy/config/core/v3/base.pb.h" #include "source/common/common/assert.h" @@ -18,6 +16,8 @@ #include "source/common/router/config_impl.h" #include "source/extensions/filters/common/processing_effect/processing_effect.h" +#include "absl/strings/escaping.h" + namespace Envoy { namespace Extensions { namespace HttpFilters { @@ -468,7 +468,8 @@ bool Filter::tryCacheHit() { return false; } - const auto& typed_metadata = decoder_callbacks_->streamInfo().dynamicMetadata().typed_filter_metadata(); + const auto& typed_metadata = + decoder_callbacks_->streamInfo().dynamicMetadata().typed_filter_metadata(); const auto cache_it = typed_metadata.find(metadata_namespace); if (cache_it == typed_metadata.end()) { return false; @@ -510,8 +511,7 @@ bool Filter::tryCacheHit() { authz_response->status = Filters::Common::ExtAuthz::CheckStatus::Denied; authz_response->status_code = Http::Code::Forbidden; if (check_response.has_denied_response()) { - copyHeaderFieldIntoResponse(authz_response, - check_response.denied_response().headers()); + copyHeaderFieldIntoResponse(authz_response, check_response.denied_response().headers()); const uint32_t status_code = check_response.denied_response().status().code(); if (status_code > 0) { authz_response->status_code = static_cast(status_code); @@ -520,8 +520,7 @@ bool Filter::tryCacheHit() { } } - if (check_response.has_ok_response() && - check_response.ok_response().has_dynamic_metadata()) { + if (check_response.has_ok_response() && check_response.ok_response().has_dynamic_metadata()) { authz_response->dynamic_metadata = check_response.ok_response().dynamic_metadata(); } else { authz_response->dynamic_metadata = check_response.dynamic_metadata(); diff --git a/test/extensions/filters/common/ext_authz/ext_authz_grpc_impl_test.cc b/test/extensions/filters/common/ext_authz/ext_authz_grpc_impl_test.cc index b52f190bf5ac2..6c63ea0ea698f 100644 --- a/test/extensions/filters/common/ext_authz/ext_authz_grpc_impl_test.cc +++ b/test/extensions/filters/common/ext_authz/ext_authz_grpc_impl_test.cc @@ -106,7 +106,8 @@ TEST_F(ExtAuthzGrpcClientTest, AuthorizationOkRawResponsePopulated) { auto check_response = std::make_unique(); check_response->mutable_status()->set_code(Grpc::Status::WellKnownGrpcStatus::Ok); - auto* metadata_fields = check_response->mutable_ok_response()->mutable_dynamic_metadata()->mutable_fields(); + auto* metadata_fields = + check_response->mutable_ok_response()->mutable_dynamic_metadata()->mutable_fields(); (*metadata_fields)["foo"] = ValueUtil::stringValue("ok"); envoy::service::auth::v3::CheckResponse expected_response = *check_response; @@ -120,7 +121,8 @@ TEST_F(ExtAuthzGrpcClientTest, AuthorizationOkRawResponsePopulated) { .WillOnce(Invoke([expected_response](ResponsePtr& response) { EXPECT_EQ(CheckStatus::OK, response->status); ASSERT_TRUE(response->raw_check_response.has_value()); - EXPECT_TRUE(TestUtility::protoEqual(expected_response, response->raw_check_response.value())); + EXPECT_TRUE( + TestUtility::protoEqual(expected_response, response->raw_check_response.value())); })); client_->onSuccess(std::move(check_response), span_); 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 9b1e6513e48b1..06997a48b262c 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 @@ -466,22 +466,22 @@ TEST_F(ExtAuthzHttpClientTest, AuthorizationOk) { } TEST_F(ExtAuthzHttpClientTest, AuthorizationOkRawResponseSynthesized) { - const auto expected_headers = TestCommon::makeHeaderValueOption({{":status", "200", false}, {"x-upstream-ok", "yes", false}}); + const auto expected_headers = TestCommon::makeHeaderValueOption( + {{":status", "200", false}, {"x-upstream-ok", "yes", false}}); auto check_response = TestCommon::makeMessageResponse(expected_headers); envoy::service::auth::v3::CheckRequest request; client_->check(request_callbacks_, request, parent_span_, stream_info_); - EXPECT_CALL(request_callbacks_, onComplete_(_)) - .WillOnce(Invoke([](ResponsePtr& response) { - EXPECT_EQ(CheckStatus::OK, response->status); - ASSERT_TRUE(response->raw_check_response.has_value()); - const auto& raw = response->raw_check_response.value(); - EXPECT_EQ(Grpc::Status::WellKnownGrpcStatus::Ok, raw.status().code()); - ASSERT_TRUE(raw.has_ok_response()); - ASSERT_EQ(1, raw.ok_response().headers_size()); - EXPECT_EQ("x-upstream-ok", raw.ok_response().headers(0).header().key()); - EXPECT_EQ("yes", raw.ok_response().headers(0).header().value()); - })); + EXPECT_CALL(request_callbacks_, onComplete_(_)).WillOnce(Invoke([](ResponsePtr& response) { + EXPECT_EQ(CheckStatus::OK, response->status); + ASSERT_TRUE(response->raw_check_response.has_value()); + const auto& raw = response->raw_check_response.value(); + EXPECT_EQ(Grpc::Status::WellKnownGrpcStatus::Ok, raw.status().code()); + ASSERT_TRUE(raw.has_ok_response()); + ASSERT_EQ(1, raw.ok_response().headers_size()); + EXPECT_EQ("x-upstream-ok", raw.ok_response().headers(0).header().key()); + EXPECT_EQ("yes", raw.ok_response().headers(0).header().value()); + })); client_->onSuccess(async_request_, std::move(check_response)); } diff --git a/test/extensions/filters/http/ext_authz/BUILD b/test/extensions/filters/http/ext_authz/BUILD index 7af6e75484633..2f27afa0b9a03 100644 --- a/test/extensions/filters/http/ext_authz/BUILD +++ b/test/extensions/filters/http/ext_authz/BUILD @@ -134,8 +134,8 @@ envoy_extension_cc_test( extension_names = ["envoy.filters.http.ext_authz"], rbe_pool = "linux_x64_small", deps = [ - "//source/extensions/filters/http/ext_authz:config", "//source/extensions/filters/http/common:pass_through_filter_lib", + "//source/extensions/filters/http/ext_authz:config", "//test/extensions/filters/http/common:empty_http_filter_config_lib", "//test/integration:http_integration_lib", "//test/test_common:utility_lib", diff --git a/test/extensions/filters/http/ext_authz/ext_authz_cache_integration_test.cc b/test/extensions/filters/http/ext_authz/ext_authz_cache_integration_test.cc index c735688367111..1ac0c88319b4c 100644 --- a/test/extensions/filters/http/ext_authz/ext_authz_cache_integration_test.cc +++ b/test/extensions/filters/http/ext_authz/ext_authz_cache_integration_test.cc @@ -2,9 +2,9 @@ #include "envoy/config/core/v3/base.pb.h" #include "envoy/extensions/filters/http/ext_authz/v3/ext_authz.pb.h" #include "envoy/extensions/filters/network/http_connection_manager/v3/http_connection_manager.pb.h" -#include "envoy/service/auth/v3/external_auth.pb.h" #include "envoy/registry/registry.h" #include "envoy/server/filter_config.h" +#include "envoy/service/auth/v3/external_auth.pb.h" #include "source/common/buffer/buffer_impl.h" #include "source/common/common/base64.h" @@ -12,22 +12,22 @@ #include "source/common/protobuf/utility.h" #include "source/extensions/filters/http/common/pass_through_filter.h" +#include "test/extensions/filters/http/common/empty_http_filter_config.h" #include "test/integration/http_integration.h" #include "test/test_common/utility.h" -#include "test/extensions/filters/http/common/empty_http_filter_config.h" #include "gtest/gtest.h" namespace Envoy { -// A simple test-only HTTP filter that acts as the "Fake Cache" caching layer in our integration test. -// It intercepts requests with 'x-simulate-cache' header, Base64-decodes and deserializes it into -// a strongly-typed CheckResponse proto, and writes it directly to dynamic typed metadata under the -// configured cache namespace before stripping the header. +// A simple test-only HTTP filter that acts as the "Fake Cache" caching layer in our integration +// test. It intercepts requests with 'x-simulate-cache' header, Base64-decodes and deserializes it +// into a strongly-typed CheckResponse proto, and writes it directly to dynamic typed metadata under +// the configured cache namespace before stripping the header. class CacheSimulatorFilter : public Http::PassThroughFilter { public: CacheSimulatorFilter() = default; - + Http::FilterHeadersStatus decodeHeaders(Http::RequestHeaderMap& headers, bool) override { const auto simulate_header = headers.get(Http::LowerCaseString("x-simulate-cache")); if (!simulate_header.empty()) { @@ -38,7 +38,7 @@ class CacheSimulatorFilter : public Http::PassThroughFilter { if (check_response.ParseFromString(decoded)) { Protobuf::Any typed_metadata; typed_metadata.PackFrom(check_response); - + // Store direct CheckResponse Any under the configured typed metadata cache namespace decoder_callbacks_->streamInfo().setDynamicTypedMetadata( "envoy.filters.http.ext_authz.cache", typed_metadata); @@ -50,7 +50,8 @@ class CacheSimulatorFilter : public Http::PassThroughFilter { } }; -class CacheSimulatorFilterConfig : public Extensions::HttpFilters::Common::EmptyHttpDualFilterConfig { +class CacheSimulatorFilterConfig + : public Extensions::HttpFilters::Common::EmptyHttpDualFilterConfig { public: CacheSimulatorFilterConfig() : EmptyHttpDualFilterConfig("cache-simulator-filter") {} absl::StatusOr @@ -66,9 +67,7 @@ static Registry::RegisterFactory register_cache_simulator_filter_; - -class ExtAuthzCacheIntegrationTest : public HttpIntegrationTest, - public testing::Test { +class ExtAuthzCacheIntegrationTest : public HttpIntegrationTest, public testing::Test { public: ExtAuthzCacheIntegrationTest() : HttpIntegrationTest(Http::CodecType::HTTP2, Network::Address::IpVersion::v4) {} @@ -94,8 +93,10 @@ class ExtAuthzCacheIntegrationTest : public HttpIntegrationTest, // 2. Set up ext_authz filter config, bypass namespace set envoy::extensions::filters::http::ext_authz::v3::ExtAuthz ext_authz_proto; - ext_authz_proto.mutable_grpc_service()->mutable_envoy_grpc()->set_cluster_name("ext_authz_cluster"); - ext_authz_proto.set_check_response_typed_metadata_namespace("envoy.filters.http.ext_authz.cache"); + ext_authz_proto.mutable_grpc_service()->mutable_envoy_grpc()->set_cluster_name( + "ext_authz_cluster"); + ext_authz_proto.set_check_response_typed_metadata_namespace( + "envoy.filters.http.ext_authz.cache"); ext_authz_proto.set_transport_api_version(envoy::config::core::v3::ApiVersion::V3); envoy::extensions::filters::network::http_connection_manager::v3::HttpFilter ext_authz_filter; @@ -104,7 +105,8 @@ class ExtAuthzCacheIntegrationTest : public HttpIntegrationTest, // Prepend filters to HCM (cache_simulator_filter first, then ext_authz) config_helper_.prependFilter(MessageUtil::getJsonStringFromMessageOrError(ext_authz_filter)); - config_helper_.prependFilter(MessageUtil::getJsonStringFromMessageOrError(cache_simulator_config)); + config_helper_.prependFilter( + MessageUtil::getJsonStringFromMessageOrError(cache_simulator_config)); }); HttpIntegrationTest::initialize(); @@ -131,18 +133,17 @@ TEST_F(ExtAuthzCacheIntegrationTest, CacheHitOKBypassesRPC) { // 2. Client connection and request codec_client_ = makeHttpConnection(makeClientConnection(lookupPort("http"))); - Http::TestRequestHeaderMapImpl headers{ - {":method", "GET"}, - {":path", "/test"}, - {":scheme", "http"}, - {":authority", "host"}, - {"x-simulate-cache", base64_cached_response} - }; + Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, + {":path", "/test"}, + {":scheme", "http"}, + {":authority", "host"}, + {"x-simulate-cache", base64_cached_response}}; auto response = codec_client_->makeHeaderOnlyRequest(headers); // 3. Verify request goes upstream with injected headers, and gRPC bypasses - AssertionResult result = fake_upstreams_[0]->waitForHttpConnection(*dispatcher_, fake_upstream_connection_); + AssertionResult result = + fake_upstreams_[0]->waitForHttpConnection(*dispatcher_, fake_upstream_connection_); RELEASE_ASSERT(result, result.message()); result = fake_upstream_connection_->waitForNewStream(*dispatcher_, upstream_request_); RELEASE_ASSERT(result, result.message()); @@ -170,20 +171,19 @@ TEST_F(ExtAuthzCacheIntegrationTest, CacheHitDeniedBypassesRPC) { envoy::service::auth::v3::CheckResponse cached_response; cached_response.mutable_status()->set_code(Grpc::Status::WellKnownGrpcStatus::PermissionDenied); auto* denied_response = cached_response.mutable_denied_response(); - denied_response->mutable_status()->set_code(static_cast(enumToInt(Http::Code::Forbidden))); + denied_response->mutable_status()->set_code( + static_cast(enumToInt(Http::Code::Forbidden))); denied_response->set_body("Cache Denied Body"); std::string base64_cached_response = serializeAndEncode(cached_response); // 2. Client request codec_client_ = makeHttpConnection(makeClientConnection(lookupPort("http"))); - Http::TestRequestHeaderMapImpl headers{ - {":method", "GET"}, - {":path", "/test"}, - {":scheme", "http"}, - {":authority", "host"}, - {"x-simulate-cache", base64_cached_response} - }; + Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, + {":path", "/test"}, + {":scheme", "http"}, + {":authority", "host"}, + {"x-simulate-cache", base64_cached_response}}; auto response = codec_client_->makeHeaderOnlyRequest(headers); diff --git a/test/extensions/filters/http/ext_authz/ext_authz_cache_test.cc b/test/extensions/filters/http/ext_authz/ext_authz_cache_test.cc index bfdbab384adf5..8548d0d08c6d0 100644 --- a/test/extensions/filters/http/ext_authz/ext_authz_cache_test.cc +++ b/test/extensions/filters/http/ext_authz/ext_authz_cache_test.cc @@ -35,12 +35,14 @@ using testing::NiceMock; using testing::Return; using testing::ReturnRef; -namespace Envoy::Extensions::HttpFilters::ExtAuthz { +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace ExtAuthz { namespace { class ExtAuthzCacheTest : public testing::Test { public: - void initialize(const std::string& yaml) { envoy::extensions::filters::http::ext_authz::v3::ExtAuthz proto_config{}; if (!yaml.empty()) { @@ -64,14 +66,15 @@ class ExtAuthzCacheTest : public testing::Test { connection_.stream_info_.downstream_connection_info_provider_->setLocalAddress(addr_); } - void setCacheMetadata(const envoy::service::auth::v3::CheckResponse& response, const std::string& metadata_namespace) { + void setCacheMetadata(const envoy::service::auth::v3::CheckResponse& response, + const std::string& metadata_namespace) { Protobuf::Any typed_metadata; typed_metadata.PackFrom(response); - + // Set it in dynamic typed metadata decoder_callbacks_metadata_.mutable_typed_filter_metadata()->insert( {metadata_namespace, typed_metadata}); - + ON_CALL(decoder_filter_callbacks_.stream_info_, dynamicMetadata()) .WillByDefault(ReturnRef(decoder_callbacks_metadata_)); } @@ -116,7 +119,7 @@ TEST_F(ExtAuthzCacheTest, CacheHitOK) { request_headers_.addCopy(Http::Headers::get().Host, "example.com"); request_headers_.addCopy(Http::Headers::get().Method, "GET"); request_headers_.addCopy(Http::Headers::get().Path, "/"); - + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers_, false)); // Verify mutations are applied @@ -138,7 +141,8 @@ TEST_F(ExtAuthzCacheTest, CacheHitDenied) { envoy::service::auth::v3::CheckResponse cached_response; cached_response.mutable_status()->set_code(Grpc::Status::WellKnownGrpcStatus::PermissionDenied); auto* denied_response = cached_response.mutable_denied_response(); - denied_response->mutable_status()->set_code(static_cast(enumToInt(Http::Code::Forbidden))); + denied_response->mutable_status()->set_code( + static_cast(enumToInt(Http::Code::Forbidden))); denied_response->set_body("Access Denied by Cache"); setCacheMetadata(cached_response, "envoy.filters.http.ext_authz.cache"); @@ -147,14 +151,16 @@ TEST_F(ExtAuthzCacheTest, CacheHitDenied) { EXPECT_CALL(*client_, check(_, _, _, _)).Times(0); // Expect local reply - EXPECT_CALL(decoder_filter_callbacks_, sendLocalReply(Http::Code::Forbidden, "Access Denied by Cache", _, _, _)); + EXPECT_CALL(decoder_filter_callbacks_, + sendLocalReply(Http::Code::Forbidden, "Access Denied by Cache", _, _, _)); // Call decodeHeaders request_headers_.addCopy(Http::Headers::get().Host, "example.com"); request_headers_.addCopy(Http::Headers::get().Method, "GET"); request_headers_.addCopy(Http::Headers::get().Path, "/"); - EXPECT_EQ(Http::FilterHeadersStatus::StopAllIterationAndWatermark, filter_->decodeHeaders(request_headers_, false)); + EXPECT_EQ(Http::FilterHeadersStatus::StopAllIterationAndWatermark, + filter_->decodeHeaders(request_headers_, false)); EXPECT_EQ(1U, config_->stats().denied_.value()); } @@ -169,9 +175,10 @@ TEST_F(ExtAuthzCacheTest, CacheMissAndRecordgRPC) { prepareCheck(); // We expect client_->check to be called - Filters::Common::ExtAuthz::ResponsePtr authz_response = std::make_unique(); + Filters::Common::ExtAuthz::ResponsePtr authz_response = + std::make_unique(); authz_response->status = Filters::Common::ExtAuthz::CheckStatus::OK; - + envoy::service::auth::v3::CheckResponse raw_response; raw_response.mutable_status()->set_code(Grpc::Status::WellKnownGrpcStatus::Ok); auto* h = raw_response.mutable_ok_response()->add_headers(); @@ -187,7 +194,8 @@ TEST_F(ExtAuthzCacheTest, CacheMissAndRecordgRPC) { })); // Expect dynamic typed metadata to be set with the cached response directly - EXPECT_CALL(decoder_filter_callbacks_.stream_info_, setDynamicTypedMetadata("envoy.filters.http.ext_authz.cache", _)) + EXPECT_CALL(decoder_filter_callbacks_.stream_info_, + setDynamicTypedMetadata("envoy.filters.http.ext_authz.cache", _)) .WillOnce(Invoke([&](const std::string&, const Protobuf::Any& metadata) { envoy::service::auth::v3::CheckResponse recorded; ASSERT_TRUE(MessageUtil::unpackTo(metadata, recorded).ok()); @@ -217,7 +225,7 @@ TEST_F(ExtAuthzCacheTest, InvalidCacheMetadataFallback) { // Set unexpected proto type in dynamic typed metadata to trigger unpack failure envoy::config::core::v3::Metadata unexpected_proto; (*unexpected_proto.mutable_filter_metadata())["unexpected"] = {}; - + Protobuf::Any typed_metadata_any; typed_metadata_any.PackFrom(unexpected_proto); @@ -231,7 +239,8 @@ TEST_F(ExtAuthzCacheTest, InvalidCacheMetadataFallback) { .WillOnce(Invoke([&](Filters::Common::ExtAuthz::RequestCallbacks& callbacks, const envoy::service::auth::v3::CheckRequest&, Tracing::Span&, const StreamInfo::StreamInfo&) -> void { - Filters::Common::ExtAuthz::ResponsePtr fallback_response = std::make_unique(); + Filters::Common::ExtAuthz::ResponsePtr fallback_response = + std::make_unique(); fallback_response->status = Filters::Common::ExtAuthz::CheckStatus::OK; callbacks.onComplete(std::move(fallback_response)); })); @@ -242,7 +251,7 @@ TEST_F(ExtAuthzCacheTest, InvalidCacheMetadataFallback) { request_headers_.addCopy(Http::Headers::get().Path, "/"); EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers_, false)); - + // Verify stats EXPECT_EQ(1U, config_->stats().invalid_cached_response_.value()); } @@ -261,7 +270,8 @@ TEST_F(ExtAuthzCacheTest, CacheHitErrorFailClosed) { envoy::service::auth::v3::CheckResponse cached_response; cached_response.mutable_status()->set_code(Grpc::Status::WellKnownGrpcStatus::Internal); auto* error_response = cached_response.mutable_error_response(); - error_response->mutable_status()->set_code(static_cast(enumToInt(Http::Code::InternalServerError))); + error_response->mutable_status()->set_code( + static_cast(enumToInt(Http::Code::InternalServerError))); error_response->set_body("Cached Error Body"); setCacheMetadata(cached_response, "envoy.filters.http.ext_authz.cache"); @@ -270,14 +280,16 @@ TEST_F(ExtAuthzCacheTest, CacheHitErrorFailClosed) { EXPECT_CALL(*client_, check(_, _, _, _)).Times(0); // Expect local reply (fail-closed) with custom status from error_response - EXPECT_CALL(decoder_filter_callbacks_, sendLocalReply(Http::Code::InternalServerError, "Cached Error Body", _, _, _)); + EXPECT_CALL(decoder_filter_callbacks_, + sendLocalReply(Http::Code::InternalServerError, "Cached Error Body", _, _, _)); // Call decodeHeaders request_headers_.addCopy(Http::Headers::get().Host, "example.com"); request_headers_.addCopy(Http::Headers::get().Method, "GET"); request_headers_.addCopy(Http::Headers::get().Path, "/"); - EXPECT_EQ(Http::FilterHeadersStatus::StopAllIterationAndWatermark, filter_->decodeHeaders(request_headers_, false)); + EXPECT_EQ(Http::FilterHeadersStatus::StopAllIterationAndWatermark, + filter_->decodeHeaders(request_headers_, false)); EXPECT_EQ(1U, config_->stats().error_.value()); } @@ -297,7 +309,8 @@ TEST_F(ExtAuthzCacheTest, CacheHitErrorFailOpen) { envoy::service::auth::v3::CheckResponse cached_response; cached_response.mutable_status()->set_code(Grpc::Status::WellKnownGrpcStatus::Internal); auto* error_response = cached_response.mutable_error_response(); - error_response->mutable_status()->set_code(static_cast(enumToInt(Http::Code::InternalServerError))); + error_response->mutable_status()->set_code( + static_cast(enumToInt(Http::Code::InternalServerError))); setCacheMetadata(cached_response, "envoy.filters.http.ext_authz.cache"); @@ -311,7 +324,7 @@ TEST_F(ExtAuthzCacheTest, CacheHitErrorFailOpen) { // Should continue decoding (fail-open) EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers_, false)); - + // Verify fail-open header is added EXPECT_EQ("true", request_headers_.get_("x-envoy-auth-failure-mode-allowed")); EXPECT_EQ(1U, config_->stats().error_.value()); @@ -319,4 +332,7 @@ TEST_F(ExtAuthzCacheTest, CacheHitErrorFailOpen) { } } // namespace -} // namespace Envoy::Extensions::HttpFilters::ExtAuthz +} // namespace ExtAuthz +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy From b413bf032b5c6b1223dc960bb62a435540a60416 Mon Sep 17 00:00:00 2001 From: Todd Greer Date: Fri, 8 May 2026 23:21:53 +0000 Subject: [PATCH 10/10] ext_authz: update cooperative caching docs and release notes Rewrote the External Authorization cooperative caching bypass documentation in ext_authz_filter.rst to accurately describe the new strongly-typed keyless direct namespace design. Updated the upcoming v1.39.0-dev changelogs in current.yaml to replace the obsolete check_response_metadata_key references and document the new typed metadata bypass. Added .gitignore rule for /docs/bazel-* to clean the workspace. Signed-off-by: Todd Greer --- .gitignore | 1 + changelogs/current.yaml | 12 ++++++------ .../http/http_filters/ext_authz_filter.rst | 15 ++++++++------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 06dcce55c4b87..bbf001787b741 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ /api/bazel-* /bazel-* /ci/bazel-* +/docs/bazel-* /mobile/bazel-* bazel.output.txt clang.bazelrc diff --git a/changelogs/current.yaml b/changelogs/current.yaml index 9d28ef733d843..944c952b02de1 100644 --- a/changelogs/current.yaml +++ b/changelogs/current.yaml @@ -116,12 +116,12 @@ new_features: config option with ``DISABLE``, ``REQUIRE``, and ``ALLOW`` modes. - area: ext_authz change: | - Added cooperative caching bypass support to the external authorization HTTP filter. The filter can - now retrieve a cached ``CheckResponse`` from dynamic metadata, Base64-decode and deserialize it, and - apply the cached response directly, bypassing the live authorization service call. If deserialization - fails, the filter increments the new ``invalid_cached_response`` statistic and gracefully falls back - to a live network call. Configured via the new - :ref:`check_response_metadata_key ` + Added strongly-typed cooperative caching bypass support to the external authorization HTTP filter. The filter can + now retrieve a cached ``CheckResponse`` directly from dynamic typed metadata, unpack it from ``google.protobuf.Any``, + and apply the cached response directly, bypassing the live authorization service call. If unpacking fails (due to type + mismatch), the filter increments the new ``invalid_cached_response`` statistic and gracefully falls back to a live + network call. Configured via the new + :ref:`check_response_typed_metadata_namespace ` option. - area: quic change: | diff --git a/docs/root/configuration/http/http_filters/ext_authz_filter.rst b/docs/root/configuration/http/http_filters/ext_authz_filter.rst index e3470d89c78f8..2f8d975f7f8c5 100644 --- a/docs/root/configuration/http/http_filters/ext_authz_filter.rst +++ b/docs/root/configuration/http/http_filters/ext_authz_filter.rst @@ -178,16 +178,17 @@ enforcement (in ext_authz), while ensuring the ext_authz filter is only instanti Cooperative Caching Bypass -------------------------- -The External Authorization filter supports bypassing the external authorization service call by retrieving a cached response from dynamic metadata. This is designed to work cooperatively with a preceding caching filter (such as a suitably configured ext_proc filter using an external cache). +The External Authorization filter supports bypassing the external authorization service call by retrieving a cached response from dynamic typed metadata. This is designed to work cooperatively with a preceding caching filter (such as a suitably configured ext_proc filter using an external cache). -To enable this, configure the :ref:`check_response_metadata_key ` with the dynamic metadata key under the ``envoy.filters.http.ext_authz`` namespace where the cached response is stored. +To enable this, configure the :ref:`check_response_typed_metadata_namespace ` with the dynamic typed metadata namespace where the cached response is stored. -When a cached response is present under the configured key: -1. The filter will Base64-decode and deserialize it into a ``CheckResponse`` proto. -2. If deserialization succeeds, the filter will bypass the external service call and apply the cached response (OK, Denied, or Error) directly. -3. If deserialization fails, the filter will increment the ``invalid_cached_response`` stat and gracefully fall back to making a live call to the external authorization service. +When a cached response is present under the configured namespace: +1. The filter will retrieve the ``google.protobuf.Any`` message directly from the dynamic typed metadata. +2. It will attempt to unpack it directly into a ``CheckResponse`` proto. +3. If unpacking succeeds, the filter will bypass the external service call and apply the cached response (OK with mutations, Denied, or Error) directly. +4. If unpacking fails (due to type mismatch), the filter will increment the ``invalid_cached_response`` stat and gracefully fall back to making a live call to the external authorization service. -On cache misses (when no cached response is found), the filter will proceed with the live call and, upon receiving a successful response, will write the serialized ``CheckResponse`` as a Base64-encoded string back to the dynamic metadata under the configured key, allowing the caching filter to record and cache it. +On cache misses (when no cached response is found under the namespace), the filter will proceed with the live call and, upon receiving a response, will pack the raw ``CheckResponse`` proto directly into a ``google.protobuf.Any`` message and write it to the dynamic typed metadata under the configured namespace, allowing the caching filter to record and cache it in raw binary format. Statistics ----------