diff --git a/CODEOWNERS b/CODEOWNERS index f305fa7cdc858..71b881ec50b9c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -339,6 +339,7 @@ extensions/upstreams/tcp @ggreenway @mattklein123 /*/extensions/filters/common/ext_authz @esmet @tyxia @ggreenway @antoniovleonti /*/extensions/filters/http/ext_authz @esmet @tyxia @ggreenway @antoniovleonti /*/extensions/filters/network/ext_authz @esmet @tyxia @ggreenway @antoniovleonti +/*/extensions/filters/udp/udp_proxy/session_filters/ext_authz @antonkanug @UNOWNED # original dst /*/extensions/filters/listener/original_dst @kyessenov @cpakulski @lambdai @nezdolik # mongo proxy diff --git a/api/BUILD b/api/BUILD index f6c9e45d37457..147a1c775f6f0 100644 --- a/api/BUILD +++ b/api/BUILD @@ -298,6 +298,7 @@ proto_library( "//envoy/extensions/filters/udp/dns_filter/v3:pkg", "//envoy/extensions/filters/udp/dynamic_modules/v3:pkg", "//envoy/extensions/filters/udp/udp_proxy/session/dynamic_forward_proxy/v3:pkg", + "//envoy/extensions/filters/udp/udp_proxy/session/ext_authz/v3:pkg", "//envoy/extensions/filters/udp/udp_proxy/session/http_capsule/v3:pkg", "//envoy/extensions/filters/udp/udp_proxy/v3:pkg", "//envoy/extensions/formatter/cel/v3:pkg", diff --git a/api/envoy/extensions/filters/udp/udp_proxy/session/ext_authz/v3/BUILD b/api/envoy/extensions/filters/udp/udp_proxy/session/ext_authz/v3/BUILD new file mode 100644 index 0000000000000..504c6c70514ac --- /dev/null +++ b/api/envoy/extensions/filters/udp/udp_proxy/session/ext_authz/v3/BUILD @@ -0,0 +1,12 @@ +# DO NOT EDIT. This file is generated by tools/proto_format/proto_sync.py. + +load("@envoy_api//bazel:api_build_system.bzl", "api_proto_package") + +licenses(["notice"]) # Apache 2 + +api_proto_package( + deps = [ + "//envoy/config/core/v3:pkg", + "@xds//udpa/annotations:pkg", + ], +) diff --git a/api/envoy/extensions/filters/udp/udp_proxy/session/ext_authz/v3/ext_authz.proto b/api/envoy/extensions/filters/udp/udp_proxy/session/ext_authz/v3/ext_authz.proto new file mode 100644 index 0000000000000..5d7e11f59d297 --- /dev/null +++ b/api/envoy/extensions/filters/udp/udp_proxy/session/ext_authz/v3/ext_authz.proto @@ -0,0 +1,57 @@ +syntax = "proto3"; + +package envoy.extensions.filters.udp.udp_proxy.session.ext_authz.v3; + +import "envoy/config/core/v3/grpc_service.proto"; + +import "google/protobuf/wrappers.proto"; + +import "udpa/annotations/status.proto"; +import "validate/validate.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.filters.udp.udp_proxy.session.ext_authz.v3"; +option java_outer_classname = "ExtAuthzProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/udp/udp_proxy/session/ext_authz/v3;ext_authzv3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: UDP session external authorization] +// UDP proxy session external authorization +// :ref:`configuration overview `. +// [#extension: envoy.filters.udp.session.ext_authz] + +// External authorization for UDP proxy sessions over the gRPC +// :ref:`CheckRequest ` API. +message FilterConfig { + // Configuration for UDP datagrams buffering while the authorization call is in flight. + message BufferOptions { + // If set, the filter will only buffer datagrams up to the requested limit, and will drop + // new UDP datagrams if the buffer contains the max_buffered_datagrams value at the time + // of a new datagram arrival. If not set, the default value is 1024 datagrams. + google.protobuf.UInt32Value max_buffered_datagrams = 1; + + // If set, the filter will only buffer datagrams up to the requested total buffered bytes limit, + // and will drop new UDP datagrams if the buffer contains the max_buffered_bytes value + // at the time of a new datagram arrival. If not set, the default value is 16,384 (16KB). + google.protobuf.UInt64Value max_buffered_bytes = 2; + } + + // The prefix to use when emitting :ref:`statistics + // `. + string stat_prefix = 1 [(validate.rules).string = {min_len: 1}]; + + // The external authorization gRPC service configuration (default timeout: 200ms). + config.core.v3.GrpcService grpc_service = 2 [(validate.rules).message = {required: true}]; + + // The filter's behaviour in case the external authorization service does not respond back, or + // when it returns an error. When set to true, the new session is established and traffic is + // forwarded to the upstream. When set to false, the session is dropped. Defaults to false. + bool failure_mode_allow = 3; + + // If configured, the filter will buffer datagrams while it is waiting for the authorization + // response. If this field is not configured, there will be no buffering and downstream datagrams + // that arrive while the authorization call is in progress will be dropped. In case this field is + // set but the options are not configured, the default values will be applied as described in the + // ``BufferOptions``. + BufferOptions buffer_options = 4; +} diff --git a/api/versioning/BUILD b/api/versioning/BUILD index 686bb76e45ba2..f6e04875a74b6 100644 --- a/api/versioning/BUILD +++ b/api/versioning/BUILD @@ -237,6 +237,7 @@ proto_library( "//envoy/extensions/filters/udp/dns_filter/v3:pkg", "//envoy/extensions/filters/udp/dynamic_modules/v3:pkg", "//envoy/extensions/filters/udp/udp_proxy/session/dynamic_forward_proxy/v3:pkg", + "//envoy/extensions/filters/udp/udp_proxy/session/ext_authz/v3:pkg", "//envoy/extensions/filters/udp/udp_proxy/session/http_capsule/v3:pkg", "//envoy/extensions/filters/udp/udp_proxy/v3:pkg", "//envoy/extensions/formatter/cel/v3:pkg", diff --git a/changelogs/current.yaml b/changelogs/current.yaml index c8a8256147a03..46a14da92654c 100644 --- a/changelogs/current.yaml +++ b/changelogs/current.yaml @@ -8,6 +8,11 @@ behavior_changes: certificates. Configurations setting this option to ``false`` will no longer have any effect and enforcement will be used. new_features: +- area: udp + change: | + Added a UDP proxy session filter :ref:`ext_authz + ` that performs + external authorization over the gRPC Authorization API when a new UDP session is established. - area: network_ext_proc change: | Added support for receiving untyped dynamic metadata from the external processing server. diff --git a/docs/root/configuration/listeners/udp_filters/session_filters/ext_authz.rst b/docs/root/configuration/listeners/udp_filters/session_filters/ext_authz.rst new file mode 100644 index 0000000000000..5e685d19af850 --- /dev/null +++ b/docs/root/configuration/listeners/udp_filters/session_filters/ext_authz.rst @@ -0,0 +1,43 @@ +.. _config_udp_session_filters_ext_authz: + +External authorization +================================== + +The external authorization session filter calls an external authorization service when a new UDP +session is established, to check whether the session is authorized. The session's downstream source +and destination addresses are sent as the ``source`` and ``destination`` peers of the request. If +the session is denied, it is dropped and no upstream is created. + +The filter uses the gRPC Authorization API defined by +:ref:`CheckRequest `. A failed check, or an error +when :ref:`failure_mode_allow +` +is not set, causes the session to be dropped. + +By default, datagrams that arrive while the authorization call is in flight are dropped. Configuring +:ref:`buffer_options +` +enables buffering of those datagrams, which are then replayed if the session is allowed. + +* This filter should be configured with the type URL ``type.googleapis.com/envoy.extensions.filters.udp.udp_proxy.session.ext_authz.v3.FilterConfig``. +* :ref:`v3 API reference ` + +.. _config_udp_session_filters_ext_authz_stats: + +Statistics +---------- + +Every configured filter has statistics rooted at *udp.session.ext_authz..* +with the following statistics: + +.. csv-table:: + :header: Name, Type, Description + :widths: 1, 1, 2 + + ok, Counter, Number of sessions allowed by the authorization service + denied, Counter, Number of sessions denied by the authorization service + error, Counter, Number of errors contacting the authorization service + failure_mode_allowed, Counter, Number of sessions allowed on error due to failure_mode_allow + total, Counter, Total number of authorization checks issued + buffer_overflow, Counter, Number of datagrams dropped while waiting for the authorization response due to the buffer being full + active, Gauge, Number of authorization checks currently in flight diff --git a/docs/root/configuration/listeners/udp_filters/udp_proxy.rst b/docs/root/configuration/listeners/udp_filters/udp_proxy.rst index a4e7a30fadd65..f573d6fa07bb7 100644 --- a/docs/root/configuration/listeners/udp_filters/udp_proxy.rst +++ b/docs/root/configuration/listeners/udp_filters/udp_proxy.rst @@ -108,6 +108,7 @@ Envoy has the following builtin UDP session filters. session_filters/http_capsule session_filters/dynamic_forward_proxy + session_filters/ext_authz .. _config_udp_listener_filters_udp_proxy_tunneling_over_http: diff --git a/source/extensions/extensions_build_config.bzl b/source/extensions/extensions_build_config.bzl index 8d92b810d835d..17dafb8ccb2ef 100644 --- a/source/extensions/extensions_build_config.bzl +++ b/source/extensions/extensions_build_config.bzl @@ -288,6 +288,7 @@ EXTENSIONS = { "envoy.filters.udp.session.http_capsule": "//source/extensions/filters/udp/udp_proxy/session_filters/http_capsule:config", "envoy.filters.udp.session.dynamic_forward_proxy": "//source/extensions/filters/udp/udp_proxy/session_filters/dynamic_forward_proxy:config", + "envoy.filters.udp.session.ext_authz": "//source/extensions/filters/udp/udp_proxy/session_filters/ext_authz:config", # # Resource monitors diff --git a/source/extensions/extensions_metadata.yaml b/source/extensions/extensions_metadata.yaml index 88f88a8aa308f..2fa8856da36c0 100644 --- a/source/extensions/extensions_metadata.yaml +++ b/source/extensions/extensions_metadata.yaml @@ -1059,6 +1059,13 @@ envoy.filters.udp.session.dynamic_forward_proxy: status: alpha type_urls: - envoy.extensions.filters.udp.udp_proxy.session.dynamic_forward_proxy.v3.FilterConfig +envoy.filters.udp.session.ext_authz: + categories: + - envoy.filters.udp.session + security_posture: robust_to_untrusted_downstream + status: alpha + type_urls: + - envoy.extensions.filters.udp.udp_proxy.session.ext_authz.v3.FilterConfig envoy.filters.upstream_network.reverse_tunnel_lifecycle: categories: - envoy.filters.upstream_network diff --git a/source/extensions/filters/udp/udp_proxy/session_filters/ext_authz/BUILD b/source/extensions/filters/udp/udp_proxy/session_filters/ext_authz/BUILD new file mode 100644 index 0000000000000..f7855b9e5fc68 --- /dev/null +++ b/source/extensions/filters/udp/udp_proxy/session_filters/ext_authz/BUILD @@ -0,0 +1,44 @@ +load( + "//bazel:envoy_build_system.bzl", + "envoy_cc_extension", + "envoy_cc_library", + "envoy_extension_package", +) + +licenses(["notice"]) # Apache 2 + +envoy_extension_package() + +envoy_cc_library( + name = "ext_authz_lib", + srcs = ["ext_authz.cc"], + hdrs = ["ext_authz.h"], + deps = [ + "//envoy/grpc:async_client_manager_interface", + "//envoy/network:filter_interface", + "//envoy/server:factory_context_interface", + "//envoy/stats:stats_macros", + "//envoy/upstream:cluster_manager_interface", + "//source/common/common:logger_lib", + "//source/common/network:utility_lib", + "//source/common/protobuf:utility_lib", + "//source/common/tracing:null_span_lib", + "//source/extensions/filters/common/ext_authz:ext_authz_grpc_lib", + "//source/extensions/filters/common/ext_authz:ext_authz_interface", + "@envoy_api//envoy/config/core/v3:pkg_cc_proto", + "@envoy_api//envoy/extensions/filters/udp/udp_proxy/session/ext_authz/v3:pkg_cc_proto", + "@envoy_api//envoy/service/auth/v3:pkg_cc_proto", + ], +) + +envoy_cc_extension( + name = "config", + srcs = ["config.cc"], + hdrs = ["config.h"], + deps = [ + ":ext_authz_lib", + "//envoy/registry", + "//source/extensions/filters/udp/udp_proxy/session_filters:factory_base_lib", + "@envoy_api//envoy/extensions/filters/udp/udp_proxy/session/ext_authz/v3:pkg_cc_proto", + ], +) diff --git a/source/extensions/filters/udp/udp_proxy/session_filters/ext_authz/config.cc b/source/extensions/filters/udp/udp_proxy/session_filters/ext_authz/config.cc new file mode 100644 index 0000000000000..bfb125f37877e --- /dev/null +++ b/source/extensions/filters/udp/udp_proxy/session_filters/ext_authz/config.cc @@ -0,0 +1,38 @@ +#include "source/extensions/filters/udp/udp_proxy/session_filters/ext_authz/config.h" + +#include "envoy/registry/registry.h" +#include "envoy/server/filter_config.h" + +#include "source/extensions/filters/udp/udp_proxy/session_filters/ext_authz/ext_authz.h" + +namespace Envoy { +namespace Extensions { +namespace UdpFilters { +namespace UdpProxy { +namespace SessionFilters { +namespace ExtAuthz { + +ExtAuthzFilterConfigFactory::ExtAuthzFilterConfigFactory() + : FactoryBase("envoy.filters.udp.session.ext_authz") {} + +FilterFactoryCb ExtAuthzFilterConfigFactory::createFilterFactoryFromProtoTyped( + const FilterConfig& proto_config, Server::Configuration::FactoryContext& context) { + ConfigSharedPtr filter_config = + std::make_shared(proto_config, context.scope(), context.serverFactoryContext()); + + return [filter_config](Network::UdpSessionFilterChainFactoryCallbacks& callbacks) -> void { + callbacks.addReadFilter(std::make_shared(filter_config, filter_config->createClient())); + }; +} + +/** + * Static registration for the UDP session ext_authz filter. @see RegisterFactory. + */ +REGISTER_FACTORY(ExtAuthzFilterConfigFactory, NamedUdpSessionFilterConfigFactory); + +} // namespace ExtAuthz +} // namespace SessionFilters +} // namespace UdpProxy +} // namespace UdpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/udp/udp_proxy/session_filters/ext_authz/config.h b/source/extensions/filters/udp/udp_proxy/session_filters/ext_authz/config.h new file mode 100644 index 0000000000000..d53b35080df27 --- /dev/null +++ b/source/extensions/filters/udp/udp_proxy/session_filters/ext_authz/config.h @@ -0,0 +1,38 @@ +#pragma once + +#include "envoy/extensions/filters/udp/udp_proxy/session/ext_authz/v3/ext_authz.pb.h" +#include "envoy/extensions/filters/udp/udp_proxy/session/ext_authz/v3/ext_authz.pb.validate.h" + +#include "source/extensions/filters/udp/udp_proxy/session_filters/factory_base.h" + +namespace Envoy { +namespace Extensions { +namespace UdpFilters { +namespace UdpProxy { +namespace SessionFilters { +namespace ExtAuthz { + +using FilterConfig = + envoy::extensions::filters::udp::udp_proxy::session::ext_authz::v3::FilterConfig; +using FilterFactoryCb = Network::UdpSessionFilterFactoryCb; + +/** + * Config registration for the UDP session ext_authz filter. @see + * NamedUdpSessionFilterConfigFactory. + */ +class ExtAuthzFilterConfigFactory : public FactoryBase { +public: + ExtAuthzFilterConfigFactory(); + +private: + FilterFactoryCb + createFilterFactoryFromProtoTyped(const FilterConfig& proto_config, + Server::Configuration::FactoryContext& context) override; +}; + +} // namespace ExtAuthz +} // namespace SessionFilters +} // namespace UdpProxy +} // namespace UdpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/udp/udp_proxy/session_filters/ext_authz/ext_authz.cc b/source/extensions/filters/udp/udp_proxy/session_filters/ext_authz/ext_authz.cc new file mode 100644 index 0000000000000..01761534344b3 --- /dev/null +++ b/source/extensions/filters/udp/udp_proxy/session_filters/ext_authz/ext_authz.cc @@ -0,0 +1,182 @@ +#include "source/extensions/filters/udp/udp_proxy/session_filters/ext_authz/ext_authz.h" + +#include + +#include "envoy/common/exception.h" +#include "envoy/config/core/v3/grpc_service.pb.h" +#include "envoy/network/listener.h" + +#include "source/common/network/utility.h" +#include "source/common/protobuf/utility.h" +#include "source/common/tracing/null_span_impl.h" + +namespace Envoy { +namespace Extensions { +namespace UdpFilters { +namespace UdpProxy { +namespace SessionFilters { +namespace ExtAuthz { + +constexpr uint32_t DefaultMaxBufferedDatagrams = 1024; +constexpr uint64_t DefaultMaxBufferedBytes = 16384; + +Config::Config(const FilterConfig& config, Stats::Scope& scope, + Server::Configuration::ServerFactoryContext& context) + : stats_scope_( + scope.createScope(absl::StrCat("udp.session.ext_authz.", config.stat_prefix(), "."))), + stats_(generateStats(*stats_scope_)), failure_mode_allow_(config.failure_mode_allow()), + timeout_(std::chrono::milliseconds( + PROTOBUF_GET_MS_OR_DEFAULT(config.grpc_service(), timeout, 200))), + buffer_enabled_(config.has_buffer_options()), + max_buffered_datagrams_(config.has_buffer_options() + ? PROTOBUF_GET_WRAPPED_OR_DEFAULT(config.buffer_options(), + max_buffered_datagrams, + DefaultMaxBufferedDatagrams) + : DefaultMaxBufferedDatagrams), + max_buffered_bytes_(config.has_buffer_options() + ? PROTOBUF_GET_WRAPPED_OR_DEFAULT(config.buffer_options(), + max_buffered_bytes, + DefaultMaxBufferedBytes) + : DefaultMaxBufferedBytes) { + auto factory_or_error = context.clusterManager().grpcAsyncClientManager().factoryForGrpcService( + config.grpc_service(), scope, true); + THROW_IF_NOT_OK_REF(factory_or_error.status()); + async_client_factory_ = std::move(factory_or_error.value()); +} + +Filters::Common::ExtAuthz::ClientPtr Config::createClient() const { + return std::make_unique( + THROW_OR_RETURN_VALUE(async_client_factory_->createUncachedRawAsyncClient(), + Grpc::RawAsyncClientPtr), + timeout_); +} + +Filter::~Filter() { + if (status_ == Status::Calling) { + // onComplete() won't run for a cancelled check, so release the active gauge here. + client_->cancel(); + config_->stats().active_.dec(); + } +} + +ReadFilterStatus Filter::onNewSession() { + // Raw UDP has no TLS/peer identity, and the session 5-tuple always supplies both addresses. + auto& attrs = *check_request_.mutable_attributes(); + const auto& provider = read_callbacks_->streamInfo().downstreamAddressProvider(); + Network::Utility::addressToProtobufAddress(*provider.remoteAddress(), + *attrs.mutable_source()->mutable_address()); + Network::Utility::addressToProtobufAddress(*provider.localAddress(), + *attrs.mutable_destination()->mutable_address()); + + status_ = Status::Calling; + config_->stats().total_.inc(); + config_->stats().active_.inc(); + + // check() may invoke onComplete() inline; calling_check_ signals that to it. + calling_check_ = true; + client_->check(*this, check_request_, Tracing::NullSpan::instance(), + read_callbacks_->streamInfo()); + calling_check_ = false; + + // A synchronous allow continues now; anything else pauses until onComplete() resumes. + if (completed_ && allowed_) { + return ReadFilterStatus::Continue; + } + return ReadFilterStatus::StopIteration; +} + +ReadFilterStatus Filter::onData(Network::UdpRecvData& data) { + if (completed_ && allowed_) { + return ReadFilterStatus::Continue; + } + + // Buffer while the check is pending (replayed on allow); drop once denied. + if (!completed_) { + maybeBufferDatagram(data); + } + return ReadFilterStatus::StopIteration; +} + +void Filter::onComplete(Filters::Common::ExtAuthz::ResponsePtr&& response) { + status_ = Status::Complete; + completed_ = true; + config_->stats().active_.dec(); + + switch (response->status) { + case Filters::Common::ExtAuthz::CheckStatus::OK: + config_->stats().ok_.inc(); + allowed_ = true; + break; + case Filters::Common::ExtAuthz::CheckStatus::Error: + config_->stats().error_.inc(); + allowed_ = config_->failureModeAllow(); + if (allowed_) { + config_->stats().failure_mode_allowed_.inc(); + } + break; + case Filters::Common::ExtAuthz::CheckStatus::Denied: + config_->stats().denied_.inc(); + allowed_ = false; + break; + } + + if (!allowed_) { + // Stay stopped so no upstream is created; the session is reaped by the idle timeout. There is + // no session-filter API to actively remove a session. + read_callbacks_->streamInfo().setResponseFlag( + StreamInfo::CoreResponseFlag::UnauthorizedExternalService); + clearBuffer(); + return; + } + + // A synchronous allow is driven by onNewSession()'s return value, not here. + if (calling_check_) { + return; + } + + // On false the session is gone and this filter may be destroyed; touch nothing after. + if (!read_callbacks_->continueFilterChain()) { + return; + } + + while (!datagrams_buffer_.empty()) { + BufferedDatagramPtr datagram = std::move(datagrams_buffer_.front()); + datagrams_buffer_.pop(); + read_callbacks_->injectDatagramToFilterChain(*datagram); + } + disableSessionBuffer(); + buffered_bytes_ = 0; +} + +void Filter::maybeBufferDatagram(Network::UdpRecvData& data) { + if (!sessionBufferEnabled()) { + return; + } + + if (datagrams_buffer_.size() == config_->maxBufferedDatagrams() || + buffered_bytes_ + data.buffer_->length() > config_->maxBufferedBytes()) { + config_->stats().buffer_overflow_.inc(); + return; + } + + auto buffered_datagram = std::make_unique(); + buffered_datagram->addresses_ = {std::move(data.addresses_.local_), + std::move(data.addresses_.peer_)}; + buffered_datagram->buffer_ = std::move(data.buffer_); + buffered_datagram->receive_time_ = data.receive_time_; + buffered_bytes_ += buffered_datagram->buffer_->length(); + datagrams_buffer_.push(std::move(buffered_datagram)); +} + +void Filter::clearBuffer() { + std::queue empty; + datagrams_buffer_.swap(empty); + buffered_bytes_ = 0; +} + +} // namespace ExtAuthz +} // namespace SessionFilters +} // namespace UdpProxy +} // namespace UdpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/udp/udp_proxy/session_filters/ext_authz/ext_authz.h b/source/extensions/filters/udp/udp_proxy/session_filters/ext_authz/ext_authz.h new file mode 100644 index 0000000000000..a4e9859fa5c8e --- /dev/null +++ b/source/extensions/filters/udp/udp_proxy/session_filters/ext_authz/ext_authz.h @@ -0,0 +1,139 @@ +#pragma once + +#include +#include +#include + +#include "envoy/extensions/filters/udp/udp_proxy/session/ext_authz/v3/ext_authz.pb.h" +#include "envoy/grpc/async_client_manager.h" +#include "envoy/network/filter.h" +#include "envoy/server/factory_context.h" +#include "envoy/service/auth/v3/external_auth.pb.h" +#include "envoy/stats/scope.h" +#include "envoy/stats/stats_macros.h" +#include "envoy/upstream/cluster_manager.h" + +#include "source/common/common/logger.h" +#include "source/extensions/filters/common/ext_authz/ext_authz.h" +#include "source/extensions/filters/common/ext_authz/ext_authz_grpc_impl.h" + +namespace Envoy { +namespace Extensions { +namespace UdpFilters { +namespace UdpProxy { +namespace SessionFilters { +namespace ExtAuthz { + +using FilterConfig = + envoy::extensions::filters::udp::udp_proxy::session::ext_authz::v3::FilterConfig; +using FilterFactoryCb = Network::UdpSessionFilterFactoryCb; +using ReadFilter = Network::UdpSessionReadFilter; +using ReadFilterStatus = Network::UdpSessionReadFilterStatus; +using ReadFilterCallbacks = Network::UdpSessionReadFilterCallbacks; +using BufferedDatagramPtr = std::unique_ptr; + +/** + * All UDP session external authorization stats. @see stats_macros.h + */ +#define ALL_UDP_SESSION_EXT_AUTHZ_STATS(COUNTER, GAUGE) \ + COUNTER(ok) \ + COUNTER(denied) \ + COUNTER(error) \ + COUNTER(failure_mode_allowed) \ + COUNTER(total) \ + COUNTER(buffer_overflow) \ + GAUGE(active, Accumulate) + +/** + * Struct definition for all UDP session external authorization stats. @see stats_macros.h + */ +struct ExtAuthzStats { + ALL_UDP_SESSION_EXT_AUTHZ_STATS(GENERATE_COUNTER_STRUCT, GENERATE_GAUGE_STRUCT) +}; + +/** + * Filter configuration, shared across all sessions and workers. Holds the gRPC client factory used + * to create a per-session ext_authz client. + */ +class Config { +public: + Config(const FilterConfig& config, Stats::Scope& scope, + Server::Configuration::ServerFactoryContext& context); + + const ExtAuthzStats& stats() const { return stats_; } + bool failureModeAllow() const { return failure_mode_allow_; } + bool bufferEnabled() const { return buffer_enabled_; } + uint32_t maxBufferedDatagrams() const { return max_buffered_datagrams_; } + uint64_t maxBufferedBytes() const { return max_buffered_bytes_; } + + // Creates a per-session gRPC ext_authz client (cheap for unary RPC, like network ext_authz). + Filters::Common::ExtAuthz::ClientPtr createClient() const; + +private: + static ExtAuthzStats generateStats(Stats::Scope& scope) { + return {ALL_UDP_SESSION_EXT_AUTHZ_STATS(POOL_COUNTER(scope), POOL_GAUGE(scope))}; + } + + const Stats::ScopeSharedPtr stats_scope_; + const ExtAuthzStats stats_; + const bool failure_mode_allow_; + const std::chrono::milliseconds timeout_; + const bool buffer_enabled_; + const uint32_t max_buffered_datagrams_; + const uint64_t max_buffered_bytes_; + Grpc::AsyncClientFactoryPtr async_client_factory_; +}; + +using ConfigSharedPtr = std::shared_ptr; + +/** + * Per-session read filter that authorizes a new UDP session against an external gRPC ext_authz + * service before any datagrams reach the upstream; denied sessions are dropped. + */ +class Filter : public ReadFilter, + public Filters::Common::ExtAuthz::RequestCallbacks, + Logger::Loggable { +public: + Filter(ConfigSharedPtr config, Filters::Common::ExtAuthz::ClientPtr&& client) + : config_(std::move(config)), client_(std::move(client)), + session_buffer_enabled_(config_->bufferEnabled()) {} + ~Filter() override; + + // Network::UdpSessionReadFilter + ReadFilterStatus onNewSession() override; + ReadFilterStatus onData(Network::UdpRecvData& data) override; + void initializeReadFilterCallbacks(ReadFilterCallbacks& callbacks) override { + read_callbacks_ = &callbacks; + } + + // Filters::Common::ExtAuthz::RequestCallbacks + void onComplete(Filters::Common::ExtAuthz::ResponsePtr&& response) override; + +private: + enum class Status { NotStarted, Calling, Complete }; + + void maybeBufferDatagram(Network::UdpRecvData& data); + void clearBuffer(); + bool sessionBufferEnabled() const { return session_buffer_enabled_; } + void disableSessionBuffer() { session_buffer_enabled_ = false; } + + const ConfigSharedPtr config_; + Filters::Common::ExtAuthz::ClientPtr client_; + ReadFilterCallbacks* read_callbacks_{}; + Status status_{Status::NotStarted}; + // True while check() is on the stack, to detect synchronous completion. + bool calling_check_{false}; + bool completed_{false}; + bool allowed_{false}; + bool session_buffer_enabled_; + envoy::service::auth::v3::CheckRequest check_request_; + uint64_t buffered_bytes_{0}; + std::queue datagrams_buffer_; +}; + +} // namespace ExtAuthz +} // namespace SessionFilters +} // namespace UdpProxy +} // namespace UdpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/udp/udp_proxy/session_filters/ext_authz/BUILD b/test/extensions/filters/udp/udp_proxy/session_filters/ext_authz/BUILD new file mode 100644 index 0000000000000..9b917940ca98c --- /dev/null +++ b/test/extensions/filters/udp/udp_proxy/session_filters/ext_authz/BUILD @@ -0,0 +1,47 @@ +load( + "//bazel:envoy_build_system.bzl", + "envoy_package", +) +load( + "//test/extensions:extensions_build_system.bzl", + "envoy_extension_cc_test", +) + +licenses(["notice"]) # Apache 2 + +envoy_package() + +envoy_extension_cc_test( + name = "ext_authz_test", + srcs = ["ext_authz_test.cc"], + extension_names = ["envoy.filters.udp.session.ext_authz"], + rbe_pool = "6gig", + deps = [ + "//source/common/buffer:buffer_lib", + "//source/extensions/filters/udp/udp_proxy/session_filters/ext_authz:config", + "//source/extensions/filters/udp/udp_proxy/session_filters/ext_authz:ext_authz_lib", + "//test/extensions/filters/common/ext_authz:ext_authz_mocks", + "//test/extensions/filters/udp/udp_proxy:mocks", + "//test/mocks/server:factory_context_mocks", + "//test/mocks/stream_info:stream_info_mocks", + "@envoy_api//envoy/extensions/filters/udp/udp_proxy/session/ext_authz/v3:pkg_cc_proto", + ], +) + +envoy_extension_cc_test( + name = "ext_authz_integration_test", + srcs = ["ext_authz_integration_test.cc"], + extension_names = ["envoy.filters.udp.session.ext_authz"], + rbe_pool = "6gig", + deps = [ + "//source/extensions/filters/udp/udp_proxy:config", + "//source/extensions/filters/udp/udp_proxy/session_filters/ext_authz:config", + "//test/common/grpc:grpc_client_integration_lib", + "//test/integration:integration_lib", + "@envoy_api//envoy/config/bootstrap/v3:pkg_cc_proto", + "@envoy_api//envoy/config/listener/v3:pkg_cc_proto", + "@envoy_api//envoy/extensions/filters/udp/udp_proxy/session/ext_authz/v3:pkg_cc_proto", + "@envoy_api//envoy/extensions/filters/udp/udp_proxy/v3:pkg_cc_proto", + "@envoy_api//envoy/service/auth/v3:pkg_cc_proto", + ], +) diff --git a/test/extensions/filters/udp/udp_proxy/session_filters/ext_authz/ext_authz_integration_test.cc b/test/extensions/filters/udp/udp_proxy/session_filters/ext_authz/ext_authz_integration_test.cc new file mode 100644 index 0000000000000..6e95479586365 --- /dev/null +++ b/test/extensions/filters/udp/udp_proxy/session_filters/ext_authz/ext_authz_integration_test.cc @@ -0,0 +1,165 @@ +#include "envoy/config/bootstrap/v3/bootstrap.pb.h" +#include "envoy/config/listener/v3/listener_components.pb.h" +#include "envoy/extensions/filters/udp/udp_proxy/session/ext_authz/v3/ext_authz.pb.h" +#include "envoy/extensions/filters/udp/udp_proxy/v3/udp_proxy.pb.h" +#include "envoy/service/auth/v3/external_auth.pb.h" + +#include "test/common/grpc/grpc_client_integration.h" +#include "test/integration/integration.h" +#include "test/test_common/network_utility.h" +#include "test/test_common/utility.h" + +#include "gtest/gtest.h" + +using testing::Eq; + +namespace Envoy { +namespace Extensions { +namespace UdpFilters { +namespace UdpProxy { +namespace SessionFilters { +namespace ExtAuthz { +namespace { + +class ExtAuthzUdpIntegrationTest : public Grpc::GrpcClientIntegrationParamTest, + public BaseIntegrationTest { +public: + ExtAuthzUdpIntegrationTest() + : BaseIntegrationTest(ipVersion(), ConfigHelper::baseUdpListenerConfig()) { + skip_tag_extraction_rule_check_ = true; + } + + void createUpstreams() override { + // fake_upstreams_[0] is the UDP proxy target; fake_upstreams_[1] is the gRPC authz server. + BaseIntegrationTest::createUpstreams(); + addFakeUpstream(Http::CodecType::HTTP2); + } + + void initializeFilter(bool failure_mode_allow) { + setUdpFakeUpstream(FakeUpstreamConfig::UdpConfig()); + + config_helper_.addConfigModifier( + [this, failure_mode_allow](envoy::config::bootstrap::v3::Bootstrap& bootstrap) { + 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"); + ConfigHelper::setHttp2(*ext_authz_cluster); + + envoy::extensions::filters::udp::udp_proxy::session::ext_authz::v3::FilterConfig + ext_authz_config; + ext_authz_config.set_stat_prefix("foo"); + ext_authz_config.set_failure_mode_allow(failure_mode_allow); + // Enable buffering so the datagram that triggered the session is forwarded on allow. + ext_authz_config.mutable_buffer_options(); + setGrpcService(*ext_authz_config.mutable_grpc_service(), "ext_authz", + fake_upstreams_.back()->localAddress()); + + const std::string udp_proxy_yaml = R"EOF( +name: udp_proxy +typed_config: + '@type': type.googleapis.com/envoy.extensions.filters.udp.udp_proxy.v3.UdpProxyConfig + stat_prefix: foo + matcher: + on_no_match: + action: + name: route + typed_config: + '@type': type.googleapis.com/envoy.extensions.filters.udp.udp_proxy.v3.Route + cluster: cluster_0 +)EOF"; + envoy::config::listener::v3::ListenerFilter listener_filter; + TestUtility::loadFromYaml(udp_proxy_yaml, listener_filter); + + envoy::extensions::filters::udp::udp_proxy::v3::UdpProxyConfig udp_proxy; + listener_filter.typed_config().UnpackTo(&udp_proxy); + auto* session_filter = udp_proxy.add_session_filters(); + session_filter->set_name("envoy.filters.udp.session.ext_authz"); + session_filter->mutable_typed_config()->PackFrom(ext_authz_config); + listener_filter.mutable_typed_config()->PackFrom(udp_proxy); + + auto* listener = bootstrap.mutable_static_resources()->mutable_listeners(0); + listener->add_listener_filters()->CopyFrom(listener_filter); + }); + + BaseIntegrationTest::initialize(); + } + + void TearDown() override { + if (fake_authz_connection_ != nullptr) { + AssertionResult result = fake_authz_connection_->close(); + RELEASE_ASSERT(result, result.message()); + result = fake_authz_connection_->waitForDisconnect(); + RELEASE_ASSERT(result, result.message()); + fake_authz_connection_ = nullptr; + } + } + + void waitForAuthzRequest() { + AssertionResult result = + fake_upstreams_[1]->waitForHttpConnection(*dispatcher_, fake_authz_connection_); + RELEASE_ASSERT(result, result.message()); + result = fake_authz_connection_->waitForNewStream(*dispatcher_, authz_request_); + RELEASE_ASSERT(result, result.message()); + result = authz_request_->waitForEndStream(*dispatcher_); + RELEASE_ASSERT(result, result.message()); + } + + void sendAuthzResponse(Grpc::Status::GrpcStatus check_status) { + authz_request_->startGrpcStream(); + envoy::service::auth::v3::CheckResponse response; + response.mutable_status()->set_code(check_status); + authz_request_->sendGrpcMessage(response); + authz_request_->finishGrpcStream(Grpc::Status::WellKnownGrpcStatus::Ok); + } + + Network::Address::InstanceConstSharedPtr listenerAddress() { + return *Network::Utility::resolveUrl( + fmt::format("udp://{}:{}", Network::Test::getLoopbackAddressUrlString(version_), + lookupPort("listener_0"))); + } + + FakeHttpConnectionPtr fake_authz_connection_; + FakeStreamPtr authz_request_; +}; + +INSTANTIATE_TEST_SUITE_P(IpVersionsClientType, ExtAuthzUdpIntegrationTest, + GRPC_CLIENT_INTEGRATION_PARAMS, + Grpc::GrpcClientIntegrationParamTest::protocolTestParamsToString); + +// An OK authorization response admits the session and forwards the datagram upstream. +TEST_P(ExtAuthzUdpIntegrationTest, AllowedForwardsDatagram) { + initializeFilter(/*failure_mode_allow=*/false); + Network::Test::UdpSyncPeer client(version_); + client.write("hello", *listenerAddress()); + + waitForAuthzRequest(); + sendAuthzResponse(Grpc::Status::WellKnownGrpcStatus::Ok); + + Network::UdpRecvData request_datagram; + ASSERT_TRUE(fake_upstreams_[0]->waitForUdpDatagram(request_datagram)); + EXPECT_EQ("hello", request_datagram.buffer_->toString()); + test_server_->waitForCounter("udp.session.ext_authz.foo.ok", 1); +} + +// A denied authorization response drops the session; no datagram reaches the upstream. +TEST_P(ExtAuthzUdpIntegrationTest, DeniedDropsDatagram) { + initializeFilter(/*failure_mode_allow=*/false); + Network::Test::UdpSyncPeer client(version_); + client.write("hello", *listenerAddress()); + + waitForAuthzRequest(); + sendAuthzResponse(Grpc::Status::WellKnownGrpcStatus::PermissionDenied); + + test_server_->waitForCounter("udp.session.ext_authz.foo.denied", 1); + Network::UdpRecvData request_datagram; + EXPECT_FALSE( + fake_upstreams_[0]->waitForUdpDatagram(request_datagram, std::chrono::milliseconds(500))); +} + +} // namespace +} // namespace ExtAuthz +} // namespace SessionFilters +} // namespace UdpProxy +} // namespace UdpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/udp/udp_proxy/session_filters/ext_authz/ext_authz_test.cc b/test/extensions/filters/udp/udp_proxy/session_filters/ext_authz/ext_authz_test.cc new file mode 100644 index 0000000000000..0dadac772d66e --- /dev/null +++ b/test/extensions/filters/udp/udp_proxy/session_filters/ext_authz/ext_authz_test.cc @@ -0,0 +1,274 @@ +#include "envoy/common/exception.h" +#include "envoy/extensions/filters/udp/udp_proxy/session/ext_authz/v3/ext_authz.pb.h" + +#include "source/common/buffer/buffer_impl.h" +#include "source/extensions/filters/udp/udp_proxy/session_filters/ext_authz/ext_authz.h" + +#include "test/extensions/filters/common/ext_authz/mocks.h" +#include "test/extensions/filters/udp/udp_proxy/mocks.h" +#include "test/mocks/server/factory_context.h" + +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +using testing::_; +using testing::Invoke; +using testing::NiceMock; +using testing::Return; +using testing::ReturnRef; + +namespace Envoy { +namespace Extensions { +namespace UdpFilters { +namespace UdpProxy { +namespace SessionFilters { +namespace ExtAuthz { +namespace { + +using CheckStatus = Filters::Common::ExtAuthz::CheckStatus; + +class ExtAuthzFilterTest : public testing::Test { +public: + // Buffering is opt-in; enable_buffering uses the default limits. + void setup(bool failure_mode_allow = false, bool enable_buffering = false) { + FilterConfig proto_config; + proto_config.set_stat_prefix("test"); + proto_config.set_failure_mode_allow(failure_mode_allow); + if (enable_buffering) { + proto_config.mutable_buffer_options(); + } + build(proto_config); + } + + void setupWithBufferLimits(uint32_t max_datagrams, uint64_t max_bytes) { + FilterConfig proto_config; + proto_config.set_stat_prefix("test"); + auto* buffer_options = proto_config.mutable_buffer_options(); + buffer_options->mutable_max_buffered_datagrams()->set_value(max_datagrams); + buffer_options->mutable_max_buffered_bytes()->set_value(max_bytes); + build(proto_config); + } + + void build(const FilterConfig& proto_config) { + config_ = + std::make_shared(proto_config, context_.scope(), context_.server_factory_context_); + + auto client = std::make_unique>(); + client_ = client.get(); + filter_ = std::make_unique(config_, std::move(client)); + filter_->initializeReadFilterCallbacks(callbacks_); + ON_CALL(callbacks_, streamInfo()).WillByDefault(ReturnRef(stream_info_)); + + // Capture the callbacks so tests can drive onComplete() asynchronously. + ON_CALL(*client_, check(_, _, _, _)) + .WillByDefault( + Invoke([this](Filters::Common::ExtAuthz::RequestCallbacks& callbacks, + const envoy::service::auth::v3::CheckRequest&, Tracing::Span&, + const StreamInfo::StreamInfo&) { request_callbacks_ = &callbacks; })); + } + + Network::UdpRecvData makeDatagram(const std::string& payload) { + Network::UdpRecvData data; + data.buffer_ = std::make_unique(payload); + return data; + } + + Filters::Common::ExtAuthz::ResponsePtr makeResponse(CheckStatus status) { + auto response = std::make_unique(); + response->status = status; + return response; + } + + NiceMock context_; + ConfigSharedPtr config_; + NiceMock* client_{}; + std::unique_ptr filter_; + NiceMock callbacks_; + NiceMock stream_info_; + Filters::Common::ExtAuthz::RequestCallbacks* request_callbacks_{}; +}; + +// A new session triggers a single check call and pauses iteration until the response arrives. +TEST_F(ExtAuthzFilterTest, AllowedResumesChainAndReplaysBufferedDatagrams) { + setup(/*failure_mode_allow=*/false, /*enable_buffering=*/true); + + EXPECT_CALL(*client_, check(_, _, _, _)); + EXPECT_EQ(ReadFilterStatus::StopIteration, filter_->onNewSession()); + + auto d1 = makeDatagram("one"); + auto d2 = makeDatagram("two"); + EXPECT_EQ(ReadFilterStatus::StopIteration, filter_->onData(d1)); + EXPECT_EQ(ReadFilterStatus::StopIteration, filter_->onData(d2)); + + EXPECT_CALL(callbacks_, continueFilterChain()).WillOnce(Return(true)); + EXPECT_CALL(callbacks_, injectDatagramToFilterChain(_)).Times(2); + request_callbacks_->onComplete(makeResponse(CheckStatus::OK)); + + EXPECT_EQ(1U, config_->stats().ok_.value()); + EXPECT_EQ(1U, config_->stats().total_.value()); + EXPECT_EQ(0U, config_->stats().active_.value()); + + auto d3 = makeDatagram("three"); + EXPECT_EQ(ReadFilterStatus::Continue, filter_->onData(d3)); +} + +// A denied session keeps iteration stopped, drops buffered datagrams, and never resumes. +TEST_F(ExtAuthzFilterTest, DeniedDropsSession) { + setup(/*failure_mode_allow=*/false, /*enable_buffering=*/true); + + EXPECT_CALL(*client_, check(_, _, _, _)); + EXPECT_EQ(ReadFilterStatus::StopIteration, filter_->onNewSession()); + + auto d1 = makeDatagram("one"); + EXPECT_EQ(ReadFilterStatus::StopIteration, filter_->onData(d1)); + + EXPECT_CALL(callbacks_, continueFilterChain()).Times(0); + EXPECT_CALL(callbacks_, injectDatagramToFilterChain(_)).Times(0); + request_callbacks_->onComplete(makeResponse(CheckStatus::Denied)); + + EXPECT_EQ(1U, config_->stats().denied_.value()); + EXPECT_EQ(0U, config_->stats().active_.value()); + + auto d2 = makeDatagram("two"); + EXPECT_EQ(ReadFilterStatus::StopIteration, filter_->onData(d2)); +} + +// On an authz service error, the session is dropped unless failure_mode_allow is set. +TEST_F(ExtAuthzFilterTest, ErrorDropsSessionByDefault) { + setup(/*failure_mode_allow=*/false); + + EXPECT_CALL(*client_, check(_, _, _, _)); + EXPECT_EQ(ReadFilterStatus::StopIteration, filter_->onNewSession()); + + EXPECT_CALL(callbacks_, continueFilterChain()).Times(0); + request_callbacks_->onComplete(makeResponse(CheckStatus::Error)); + + EXPECT_EQ(1U, config_->stats().error_.value()); + EXPECT_EQ(0U, config_->stats().failure_mode_allowed_.value()); +} + +// With failure_mode_allow set, an authz service error admits the session. +TEST_F(ExtAuthzFilterTest, ErrorAllowedWithFailureModeAllow) { + setup(/*failure_mode_allow=*/true); + + EXPECT_CALL(*client_, check(_, _, _, _)); + EXPECT_EQ(ReadFilterStatus::StopIteration, filter_->onNewSession()); + + EXPECT_CALL(callbacks_, continueFilterChain()).WillOnce(Return(true)); + request_callbacks_->onComplete(makeResponse(CheckStatus::Error)); + + EXPECT_EQ(1U, config_->stats().error_.value()); + EXPECT_EQ(1U, config_->stats().failure_mode_allowed_.value()); +} + +// A synchronous (inline) OK completion lets onNewSession() continue without re-entering the chain. +TEST_F(ExtAuthzFilterTest, SynchronousAllowedContinuesInline) { + setup(); + + ON_CALL(*client_, check(_, _, _, _)) + .WillByDefault(Invoke([](Filters::Common::ExtAuthz::RequestCallbacks& callbacks, + const envoy::service::auth::v3::CheckRequest&, Tracing::Span&, + const StreamInfo::StreamInfo&) { + auto response = std::make_unique(); + response->status = CheckStatus::OK; + callbacks.onComplete(std::move(response)); + })); + + EXPECT_CALL(callbacks_, continueFilterChain()).Times(0); + EXPECT_EQ(ReadFilterStatus::Continue, filter_->onNewSession()); + EXPECT_EQ(1U, config_->stats().ok_.value()); +} + +// An in-flight check is cancelled when the filter is destroyed. +TEST_F(ExtAuthzFilterTest, CancelInflightCheckOnDestruction) { + setup(); + + EXPECT_CALL(*client_, check(_, _, _, _)); + EXPECT_EQ(ReadFilterStatus::StopIteration, filter_->onNewSession()); + EXPECT_EQ(1U, config_->stats().active_.value()); + + EXPECT_CALL(*client_, cancel()); + filter_.reset(); + + // The active gauge must be released even though onComplete never ran. + EXPECT_EQ(0U, config_->stats().active_.value()); +} + +// If the session was removed during resume, the filter must not inject buffered datagrams. +TEST_F(ExtAuthzFilterTest, ContinueFilterChainFailureDoesNotInject) { + setup(/*failure_mode_allow=*/false, /*enable_buffering=*/true); + + EXPECT_CALL(*client_, check(_, _, _, _)); + EXPECT_EQ(ReadFilterStatus::StopIteration, filter_->onNewSession()); + + auto d1 = makeDatagram("one"); + EXPECT_EQ(ReadFilterStatus::StopIteration, filter_->onData(d1)); + + EXPECT_CALL(callbacks_, continueFilterChain()).WillOnce(Return(false)); + EXPECT_CALL(callbacks_, injectDatagramToFilterChain(_)).Times(0); + request_callbacks_->onComplete(makeResponse(CheckStatus::OK)); +} + +// Without buffer_options, datagrams arriving during the check are dropped, not replayed. +TEST_F(ExtAuthzFilterTest, BufferingDisabledByDefault) { + setup(); + EXPECT_FALSE(config_->bufferEnabled()); + + EXPECT_CALL(*client_, check(_, _, _, _)); + EXPECT_EQ(ReadFilterStatus::StopIteration, filter_->onNewSession()); + + auto d1 = makeDatagram("one"); + EXPECT_EQ(ReadFilterStatus::StopIteration, filter_->onData(d1)); + + EXPECT_CALL(callbacks_, continueFilterChain()).WillOnce(Return(true)); + EXPECT_CALL(callbacks_, injectDatagramToFilterChain(_)).Times(0); + request_callbacks_->onComplete(makeResponse(CheckStatus::OK)); +} + +// An empty buffer_options enables buffering with the default limits. +TEST_F(ExtAuthzFilterTest, DefaultBufferLimits) { + setup(/*failure_mode_allow=*/false, /*enable_buffering=*/true); + EXPECT_TRUE(config_->bufferEnabled()); + EXPECT_EQ(1024, config_->maxBufferedDatagrams()); + EXPECT_EQ(16384, config_->maxBufferedBytes()); +} + +// Datagrams beyond the configured buffer limits are dropped and counted. +TEST_F(ExtAuthzFilterTest, BufferOverflowIsCountedAndDropped) { + setupWithBufferLimits(/*max_datagrams=*/1, /*max_bytes=*/1024); + EXPECT_EQ(1, config_->maxBufferedDatagrams()); + + EXPECT_CALL(*client_, check(_, _, _, _)); + EXPECT_EQ(ReadFilterStatus::StopIteration, filter_->onNewSession()); + + auto d1 = makeDatagram("one"); + auto d2 = makeDatagram("two"); + EXPECT_EQ(ReadFilterStatus::StopIteration, filter_->onData(d1)); + EXPECT_EQ(ReadFilterStatus::StopIteration, filter_->onData(d2)); + EXPECT_EQ(1U, config_->stats().buffer_overflow_.value()); + + EXPECT_CALL(callbacks_, continueFilterChain()).WillOnce(Return(true)); + EXPECT_CALL(callbacks_, injectDatagramToFilterChain(_)); + request_callbacks_->onComplete(makeResponse(CheckStatus::OK)); +} + +// A failure to create the gRPC client factory is surfaced as a configuration exception. +TEST_F(ExtAuthzFilterTest, ConfigThrowsWhenGrpcClientFactoryFails) { + EXPECT_CALL(context_.server_factory_context_.cluster_manager_.async_client_manager_, + factoryForGrpcService(_, _, _)) + .WillOnce(Return(absl::InvalidArgumentError("bad grpc service"))); + + FilterConfig proto_config; + proto_config.set_stat_prefix("test"); + EXPECT_THROW( + std::make_shared(proto_config, context_.scope(), context_.server_factory_context_), + EnvoyException); +} + +} // namespace +} // namespace ExtAuthz +} // namespace SessionFilters +} // namespace UdpProxy +} // namespace UdpFilters +} // namespace Extensions +} // namespace Envoy