diff --git a/docs/configuration/http_conn_man/route_config/route.rst b/docs/configuration/http_conn_man/route_config/route.rst index 836e3e41f6c29..473a6d33e5ad4 100644 --- a/docs/configuration/http_conn_man/route_config/route.rst +++ b/docs/configuration/http_conn_man/route_config/route.rst @@ -153,7 +153,8 @@ Global rate limit :ref:`architecture overview `. .. code-block:: json { - "global": "..." + "global": "...", + "route_key": "..." } global @@ -161,6 +162,11 @@ global request that matches this route. This information is used by the :ref:`rate limit filter ` if it is installed. Defaults to false if not specified. +route_key + *(optional, string)* Specifies a descriptor value to be used when rate limiting for a route. + This information is used by the :ref:`rate limit filter + ` if it is installed. + .. _config_http_conn_man_route_table_route_shadow: Shadow diff --git a/docs/configuration/http_filters/rate_limit_filter.rst b/docs/configuration/http_filters/rate_limit_filter.rst index c8fe975a36df7..86e80fdfb7b24 100644 --- a/docs/configuration/http_filters/rate_limit_filter.rst +++ b/docs/configuration/http_filters/rate_limit_filter.rst @@ -40,8 +40,8 @@ Actions } type - *(required, string) The type of rate limit action to perform. The currently supported action - type is *service_to_service*. + *(required, string)* The type of rate limit action to perform. The currently supported action + types are *service_to_service* and *request_headers*. Service to service ^^^^^^^^^^^^^^^^^^ @@ -60,6 +60,33 @@ The following descriptors are sent: is derived from the :option:`--service-cluster` option. +Request Headers +^^^^^^^^^^^^^^^ + +.. code-block:: json + + { + "type": "request_headers", + "header_name": "...", + "descriptor_key" : "..." + } + +header_name + *(required, string)* The header name to be queried from the request headers and used to + populate the descriptor value for the *descriptor_key*. + +descriptor_key + *(required, string)* The key to use in the descriptor. + +The following descriptor is sent when a header contains a key that matches the *header_name*: + + * ("", "") + +If *route_key* is set in the :ref:`route `, the following +descriptor is sent as well: + + * ("route_key", ""), ("", "") + Statistics ---------- diff --git a/include/envoy/router/router.h b/include/envoy/router/router.h index 7bda1768e22d7..cd4358088b735 100644 --- a/include/envoy/router/router.h +++ b/include/envoy/router/router.h @@ -90,6 +90,11 @@ class RateLimitPolicy { * @return whether the global rate limiting service should be called for the owning route. */ virtual bool doGlobalLimiting() const PURE; + + /** + * @return the route key, if it exists. + */ + virtual const std::string& routeKey() const PURE; }; /** diff --git a/source/common/http/async_client_impl.h b/source/common/http/async_client_impl.h index 93edfddc25e17..bb9d5ff247c79 100644 --- a/source/common/http/async_client_impl.h +++ b/source/common/http/async_client_impl.h @@ -61,6 +61,7 @@ class AsyncRequestImpl final : public AsyncClient::Request, struct NullRateLimitPolicy : public Router::RateLimitPolicy { // Router::RateLimitPolicy bool doGlobalLimiting() const override { return false; } + const std::string& routeKey() const override { return EMPTY_STRING; } }; struct NullRetryPolicy : public Router::RetryPolicy { diff --git a/source/common/http/filter/ratelimit.cc b/source/common/http/filter/ratelimit.cc index 70fb1de03a223..0204413edebcf 100644 --- a/source/common/http/filter/ratelimit.cc +++ b/source/common/http/filter/ratelimit.cc @@ -16,7 +16,7 @@ const Http::HeaderMapImpl Filter::TOO_MANY_REQUESTS_HEADER{ void ServiceToServiceAction::populateDescriptors(const Router::RouteEntry& route, std::vector<::RateLimit::Descriptor>& descriptors, - FilterConfig& config) { + FilterConfig& config, const HeaderMap&) { // We limit on 2 dimensions. // 1) All calls to the given cluster. // 2) Calls to the given cluster and from this cluster. @@ -26,6 +26,24 @@ void ServiceToServiceAction::populateDescriptors(const Router::RouteEntry& route {{{"to_cluster", route.clusterName()}, {"from_cluster", config.localServiceCluster()}}}); } +void RequestHeadersAction::populateDescriptors(const Router::RouteEntry& route, + std::vector<::RateLimit::Descriptor>& descriptors, + FilterConfig&, const HeaderMap& headers) { + std::string header_value = headers.get(header_name_); + if (header_value.empty()) { + return; + } + + descriptors.push_back({{{descriptor_key_, header_value}}}); + + const std::string& route_key = route.rateLimitPolicy().routeKey(); + if (route_key.empty()) { + return; + } + + descriptors.push_back({{{"route_key", route_key}, {descriptor_key_, header_value}}}); +} + FilterConfig::FilterConfig(const Json::Object& config, const std::string& local_service_cluster, Stats::Store& stats_store, Runtime::Loader& runtime) : domain_(config.getString("domain")), local_service_cluster_(local_service_cluster), @@ -34,6 +52,8 @@ FilterConfig::FilterConfig(const Json::Object& config, const std::string& local_ std::string type = action.getString("type"); if (type == "service_to_service") { actions_.emplace_back(new ServiceToServiceAction()); + } else if (type == "request_headers") { + actions_.emplace_back(new RequestHeadersAction(action)); } else { throw EnvoyException(fmt::format("unknown http rate limit filter action '{}'", type)); } @@ -49,7 +69,7 @@ FilterHeadersStatus Filter::decodeHeaders(HeaderMap& headers, bool) { if (route && route->rateLimitPolicy().doGlobalLimiting()) { std::vector<::RateLimit::Descriptor> descriptors; for (const ActionPtr& action : config_->actions()) { - action->populateDescriptors(*route, descriptors, *config_); + action->populateDescriptors(*route, descriptors, *config_, headers); } if (!descriptors.empty()) { diff --git a/source/common/http/filter/ratelimit.h b/source/common/http/filter/ratelimit.h index 5ae78cea4e6eb..ba0297a0b7973 100644 --- a/source/common/http/filter/ratelimit.h +++ b/source/common/http/filter/ratelimit.h @@ -27,7 +27,7 @@ class Action { */ virtual void populateDescriptors(const Router::RouteEntry& route, std::vector<::RateLimit::Descriptor>& descriptors, - FilterConfig& config) PURE; + FilterConfig& config, const HeaderMap& headers) PURE; }; typedef std::unique_ptr ActionPtr; @@ -39,10 +39,27 @@ class ServiceToServiceAction : public Action { public: // Action void populateDescriptors(const Router::RouteEntry& route, - std::vector<::RateLimit::Descriptor>& descriptors, - FilterConfig& config) override; + std::vector<::RateLimit::Descriptor>& descriptors, FilterConfig& config, + const HeaderMap&) override; }; +/** + * Action for request headers rate limiting. + */ +class RequestHeadersAction : public Action { +public: + RequestHeadersAction(const Json::Object& action) + : header_name_(action.getString("header_name")), + descriptor_key_(action.getString("descriptor_key")) {} + // Action + void populateDescriptors(const Router::RouteEntry& route, + std::vector<::RateLimit::Descriptor>& descriptors, FilterConfig& config, + const HeaderMap& headers) override; + +private: + const LowerCaseString header_name_; + const std::string descriptor_key_; +}; /** * Global configuration for the HTTP rate limit filter. */ diff --git a/source/common/router/config_impl.cc b/source/common/router/config_impl.cc index a3fd0b5eedfeb..57d50d28f7adc 100644 --- a/source/common/router/config_impl.cc +++ b/source/common/router/config_impl.cc @@ -28,14 +28,6 @@ RetryPolicyImpl::RetryPolicyImpl(const Json::Object& config) { retry_on_ = RetryStateImpl::parseRetryOn(config.getObject("retry_policy").getString("retry_on")); } -RateLimitPolicyImpl::RateLimitPolicyImpl(const Json::Object& config) { - if (!config.hasObject("rate_limit")) { - return; - } - - do_global_limiting_ = config.getObject("rate_limit").getBoolean("global", false); -} - ShadowPolicyImpl::ShadowPolicyImpl(const Json::Object& config) { if (!config.hasObject("shadow")) { return; diff --git a/source/common/router/config_impl.h b/source/common/router/config_impl.h index dc02bb309b03d..5957468adeb06 100644 --- a/source/common/router/config_impl.h +++ b/source/common/router/config_impl.h @@ -122,13 +122,19 @@ class RetryPolicyImpl : public RetryPolicy { */ class RateLimitPolicyImpl : public RateLimitPolicy { public: - RateLimitPolicyImpl(const Json::Object& config); + RateLimitPolicyImpl(const Json::Object& config) + : do_global_limiting_(config.getObject("rate_limit", true).getBoolean("global", false)), + route_key_(config.getObject("rate_limit", true).getString("route_key", "")) {} // Router::RateLimitPolicy bool doGlobalLimiting() const override { return do_global_limiting_; } + // Router::RateLimitPolicy + const std::string& routeKey() const override { return route_key_; } + private: - bool do_global_limiting_{}; + const bool do_global_limiting_; + const std::string route_key_; }; /** diff --git a/test/common/http/filter/ratelimit_test.cc b/test/common/http/filter/ratelimit_test.cc index 41f16803e1667..501394b4cc0e5 100644 --- a/test/common/http/filter/ratelimit_test.cc +++ b/test/common/http/filter/ratelimit_test.cc @@ -16,7 +16,7 @@ using testing::WithArgs; namespace Http { namespace RateLimit { -TEST(HttpRateLimitFilterBadConfigTest, All) { +TEST(HttpRateLimitFilterBadConfigTest, BadType) { std::string json = R"EOF( { "domain": "foo", @@ -32,23 +32,35 @@ TEST(HttpRateLimitFilterBadConfigTest, All) { EXPECT_THROW(FilterConfig(config, "service_cluster", stats_store, runtime), EnvoyException); } +TEST(HttpRateLimitFilterBadConfigTest, NoDescriptorKey) { + std::string json = R"EOF( + { + "domain": "foo", + "actions": [ + { + "type": "request_headers", + "header_name" : "test" + } + ] + } + )EOF"; + + Json::StringLoader config(json); + Stats::IsolatedStoreImpl stats_store; + NiceMock runtime; + EXPECT_THROW(FilterConfig(config, "service_cluster", stats_store, runtime), EnvoyException); +} + class HttpRateLimitFilterTest : public testing::Test { public: HttpRateLimitFilterTest() { - std::string json = R"EOF( - { - "domain": "foo", - "actions": [ - {"type": "service_to_service"} - ] - } - )EOF"; - ON_CALL(runtime_.snapshot_, featureEnabled("ratelimit.http_filter_enabled", 100)) .WillByDefault(Return(true)); ON_CALL(runtime_.snapshot_, featureEnabled("ratelimit.http_filter_enforcing", 100)) .WillByDefault(Return(true)); + } + void SetUpTest(const std::string json) { Json::StringLoader config(json); config_.reset(new FilterConfig(config, "service_cluster", stats_store_, runtime_)); @@ -57,6 +69,28 @@ class HttpRateLimitFilterTest : public testing::Test { filter_->setDecoderFilterCallbacks(filter_callbacks_); } + const std::string service_to_service_json = R"EOF( + { + "domain": "foo", + "actions": [ + {"type": "service_to_service"} + ] + } + )EOF"; + + const std::string request_headers_json = R"EOF( + { + "domain": "foobar", + "actions": [ + { + "type": "request_headers", + "header_name": "x-header-name", + "descriptor_key" : "my_header_name" + } + ] + } + )EOF"; + FilterConfigPtr config_; ::RateLimit::MockClient* client_; std::unique_ptr filter_; @@ -69,6 +103,8 @@ class HttpRateLimitFilterTest : public testing::Test { }; TEST_F(HttpRateLimitFilterTest, NoRoute) { + SetUpTest(service_to_service_json); + EXPECT_CALL(filter_callbacks_.route_table_, routeForRequest(_)).WillOnce(Return(nullptr)); EXPECT_EQ(FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers_, false)); @@ -77,12 +113,16 @@ TEST_F(HttpRateLimitFilterTest, NoRoute) { } TEST_F(HttpRateLimitFilterTest, NoLimiting) { + SetUpTest(service_to_service_json); + EXPECT_EQ(FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers_, false)); EXPECT_EQ(FilterDataStatus::Continue, filter_->decodeData(data_, false)); EXPECT_EQ(FilterTrailersStatus::Continue, filter_->decodeTrailers(request_headers_)); } TEST_F(HttpRateLimitFilterTest, RuntimeDisabled) { + SetUpTest(service_to_service_json); + EXPECT_CALL(runtime_.snapshot_, featureEnabled("ratelimit.http_filter_enabled", 100)) .WillOnce(Return(false)); EXPECT_EQ(FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers_, false)); @@ -91,6 +131,7 @@ TEST_F(HttpRateLimitFilterTest, RuntimeDisabled) { } TEST_F(HttpRateLimitFilterTest, OkResponse) { + SetUpTest(service_to_service_json); InSequence s; filter_callbacks_.route_table_.route_entry_.rate_limit_policy_.do_global_limiting_ = true; @@ -114,6 +155,7 @@ TEST_F(HttpRateLimitFilterTest, OkResponse) { } TEST_F(HttpRateLimitFilterTest, ImmediateOkResponse) { + SetUpTest(service_to_service_json); InSequence s; filter_callbacks_.route_table_.route_entry_.rate_limit_policy_.do_global_limiting_ = true; @@ -136,6 +178,7 @@ TEST_F(HttpRateLimitFilterTest, ImmediateOkResponse) { } TEST_F(HttpRateLimitFilterTest, ErrorResponse) { + SetUpTest(service_to_service_json); InSequence s; filter_callbacks_.route_table_.route_entry_.rate_limit_policy_.do_global_limiting_ = true; @@ -156,6 +199,7 @@ TEST_F(HttpRateLimitFilterTest, ErrorResponse) { } TEST_F(HttpRateLimitFilterTest, LimitResponse) { + SetUpTest(service_to_service_json); InSequence s; filter_callbacks_.route_table_.route_entry_.rate_limit_policy_.do_global_limiting_ = true; @@ -177,6 +221,7 @@ TEST_F(HttpRateLimitFilterTest, LimitResponse) { } TEST_F(HttpRateLimitFilterTest, LimitResponseRuntimeDisabled) { + SetUpTest(service_to_service_json); InSequence s; filter_callbacks_.route_table_.route_entry_.rate_limit_policy_.do_global_limiting_ = true; @@ -201,6 +246,7 @@ TEST_F(HttpRateLimitFilterTest, LimitResponseRuntimeDisabled) { } TEST_F(HttpRateLimitFilterTest, ResetDuringCall) { + SetUpTest(service_to_service_json); InSequence s; filter_callbacks_.route_table_.route_entry_.rate_limit_policy_.do_global_limiting_ = true; @@ -215,5 +261,63 @@ TEST_F(HttpRateLimitFilterTest, ResetDuringCall) { filter_callbacks_.reset_callback_(); } +TEST_F(HttpRateLimitFilterTest, RequestHeaderOkResponse) { + SetUpTest(request_headers_json); + + filter_callbacks_.route_table_.route_entry_.rate_limit_policy_.do_global_limiting_ = true; + + EXPECT_CALL(*client_, + limit(_, "foobar", testing::ContainerEq(std::vector<::RateLimit::Descriptor>{ + {{{"my_header_name", "test_value"}}}}))) + .WillOnce(WithArgs<0>(Invoke([&](::RateLimit::RequestCallbacks& callbacks) + -> void { request_callbacks_ = &callbacks; }))); + + HeaderMapImpl request_header{{"x-header-name", "test_value"}}; + EXPECT_EQ(FilterHeadersStatus::StopIteration, filter_->decodeHeaders(request_header, false)); + EXPECT_EQ(FilterDataStatus::StopIterationAndBuffer, filter_->decodeData(data_, false)); + EXPECT_EQ(FilterTrailersStatus::StopIteration, filter_->decodeTrailers(request_header)); + + EXPECT_CALL(filter_callbacks_, continueDecoding()); + request_callbacks_->complete(::RateLimit::LimitStatus::OK); + + EXPECT_EQ(1U, stats_store_.counter("cluster.fake_cluster.ratelimit.ok").value()); +} + +TEST_F(HttpRateLimitFilterTest, RateLimitKeyOkResponse) { + SetUpTest(request_headers_json); + + filter_callbacks_.route_table_.route_entry_.rate_limit_policy_.do_global_limiting_ = true; + filter_callbacks_.route_table_.route_entry_.rate_limit_policy_.route_key_ = "test_key"; + + EXPECT_CALL( + *client_, + limit(_, "foobar", testing::ContainerEq(std::vector<::RateLimit::Descriptor>{ + {{{"my_header_name", "test_value"}}}, + {{{"route_key", "test_key"}, {"my_header_name", "test_value"}}}}))) + .WillOnce(WithArgs<0>(Invoke([&](::RateLimit::RequestCallbacks& callbacks) + -> void { request_callbacks_ = &callbacks; }))); + + HeaderMapImpl request_header{{"x-header-name", "test_value"}}; + EXPECT_EQ(FilterHeadersStatus::StopIteration, filter_->decodeHeaders(request_header, false)); + EXPECT_EQ(FilterDataStatus::StopIterationAndBuffer, filter_->decodeData(data_, false)); + EXPECT_EQ(FilterTrailersStatus::StopIteration, filter_->decodeTrailers(request_header)); + + EXPECT_CALL(filter_callbacks_, continueDecoding()); + request_callbacks_->complete(::RateLimit::LimitStatus::OK); + + EXPECT_EQ(1U, stats_store_.counter("cluster.fake_cluster.ratelimit.ok").value()); +} + +TEST_F(HttpRateLimitFilterTest, NoRateLimitHeaderMatch) { + SetUpTest(request_headers_json); + filter_callbacks_.route_table_.route_entry_.rate_limit_policy_.do_global_limiting_ = true; + + EXPECT_CALL(*client_, limit(_, _, _)).Times(0); + + EXPECT_EQ(FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers_, false)); + EXPECT_EQ(FilterDataStatus::Continue, filter_->decodeData(data_, false)); + EXPECT_EQ(FilterTrailersStatus::Continue, filter_->decodeTrailers(request_headers_)); +} + } // RateLimit } // Http diff --git a/test/mocks/router/mocks.h b/test/mocks/router/mocks.h index 84e233ce35d8a..57060a0d539da 100644 --- a/test/mocks/router/mocks.h +++ b/test/mocks/router/mocks.h @@ -44,7 +44,11 @@ class TestRateLimitPolicy : public RateLimitPolicy { // Router::RateLimitPolicy bool doGlobalLimiting() const override { return do_global_limiting_; } + // Router::RateLimitPolicy + const std::string& routeKey() const override { return route_key_; } + bool do_global_limiting_{}; + std::string route_key_; }; class TestShadowPolicy : public ShadowPolicy {