mirror of https://github.com/grpc/grpc.git
[xDS] implement GCP Auth filter (#37550)
Final piece of gRFC A83 (https://github.com/grpc/proposal/pull/438): the GCP authentication filter itself.
Infrastructure changes include:
- Added a general-purpose LRU cache library that can be reused elsewhere.
- Fixed the client channel code to use the channel args returned by the resolver for the dynamic filters. This was necessary so that the GCP auth filter could access the `XdsConfig` object, which is passed via a channel arg.
- Unlike the other xDS HTTP filters we support, the GCP auth filter does not support config overrides, and its configuration includes a cache size parameter that we always need at the channel level, not per-call. As a result, I had to change the xDS HTTP filter API to give it the ability to set top-level fields in the service config, not just per-method fields. (We use the service config as a way of passing configuration down into xDS HTTP filters.) Note that for now, this works only on the client side, because we don't have machinery for a top-level service config on the server side.
- The GCP auth filter is also the first case where the filter needs to know its instance name from the xDS config, so I changed the xDS HTTP filter API to plumb that through.
- Fixed a bug in the HTTP client library that prevented the override functions from declining to override a particular request.
Closes #37550
COPYBARA_INTEGRATE_REVIEW=https://github.com/grpc/grpc/pull/37550 from markdroth:xds_gcp_auth_filter 19eaefb52f
PiperOrigin-RevId: 669371249
pull/37613/head
parent
24e341be62
commit
c4117e4615
59 changed files with 2545 additions and 131 deletions
@ -0,0 +1,167 @@ |
||||
//
|
||||
// Copyright 2024 gRPC authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
#include "src/core/ext/filters/gcp_authentication/gcp_authentication_filter.h" |
||||
|
||||
#include <memory> |
||||
#include <string> |
||||
#include <utility> |
||||
|
||||
#include "absl/log/check.h" |
||||
#include "absl/strings/str_cat.h" |
||||
|
||||
#include "src/core/ext/filters/gcp_authentication/gcp_authentication_service_config_parser.h" |
||||
#include "src/core/lib/channel/channel_stack.h" |
||||
#include "src/core/lib/config/core_configuration.h" |
||||
#include "src/core/lib/promise/context.h" |
||||
#include "src/core/lib/resource_quota/arena.h" |
||||
#include "src/core/lib/security/context/security_context.h" |
||||
#include "src/core/lib/security/credentials/gcp_service_account_identity/gcp_service_account_identity_credentials.h" |
||||
#include "src/core/lib/transport/transport.h" |
||||
#include "src/core/resolver/xds/xds_resolver_attributes.h" |
||||
#include "src/core/service_config/service_config.h" |
||||
#include "src/core/service_config/service_config_call_data.h" |
||||
|
||||
namespace grpc_core { |
||||
|
||||
const NoInterceptor GcpAuthenticationFilter::Call::OnClientToServerMessage; |
||||
const NoInterceptor GcpAuthenticationFilter::Call::OnClientToServerHalfClose; |
||||
const NoInterceptor GcpAuthenticationFilter::Call::OnServerInitialMetadata; |
||||
const NoInterceptor GcpAuthenticationFilter::Call::OnServerToClientMessage; |
||||
const NoInterceptor GcpAuthenticationFilter::Call::OnServerTrailingMetadata; |
||||
const NoInterceptor GcpAuthenticationFilter::Call::OnFinalize; |
||||
|
||||
absl::Status GcpAuthenticationFilter::Call::OnClientInitialMetadata( |
||||
ClientMetadata& /*md*/, GcpAuthenticationFilter* filter) { |
||||
// Get the cluster name chosen for this RPC.
|
||||
auto* service_config_call_data = GetContext<ServiceConfigCallData>(); |
||||
auto cluster_attribute = |
||||
service_config_call_data->GetCallAttribute<XdsClusterAttribute>(); |
||||
if (cluster_attribute == nullptr) { |
||||
// Can't happen, but be defensive.
|
||||
return absl::InternalError( |
||||
"GCP authentication filter: call has no xDS cluster attribute"); |
||||
} |
||||
absl::string_view cluster_name = cluster_attribute->cluster(); |
||||
if (!absl::ConsumePrefix(&cluster_name, "cluster:")) { |
||||
return absl::OkStatus(); // Cluster specifier plugin.
|
||||
} |
||||
// Look up the CDS resource for the cluster.
|
||||
auto it = filter->xds_config_->clusters.find(cluster_name); |
||||
if (it == filter->xds_config_->clusters.end()) { |
||||
// Can't happen, but be defensive.
|
||||
return absl::InternalError( |
||||
absl::StrCat("GCP authentication filter: xDS cluster ", cluster_name, |
||||
" not found in XdsConfig")); |
||||
} |
||||
if (!it->second.ok()) { |
||||
// Cluster resource had an error, so fail the call.
|
||||
// Note: For wait_for_ready calls, this does the wrong thing by
|
||||
// failing the call instead of queuing it, but there's no easy
|
||||
// way to queue the call here until we get a valid CDS resource,
|
||||
// because once that happens, a new instance of this filter will be
|
||||
// swapped in for subsequent calls, but *this* call is already tied
|
||||
// to this filter instance, which will never see the update.
|
||||
return absl::UnavailableError( |
||||
absl::StrCat("GCP authentication filter: CDS resource unavailable for ", |
||||
cluster_name)); |
||||
} |
||||
if (it->second->cluster == nullptr) { |
||||
// Can't happen, but be defensive.
|
||||
return absl::InternalError(absl::StrCat( |
||||
"GCP authentication filter: CDS resource not present for cluster ", |
||||
cluster_name)); |
||||
} |
||||
auto& metadata_map = it->second->cluster->metadata; |
||||
const XdsMetadataValue* metadata_value = |
||||
metadata_map.Find(filter->filter_config_->filter_instance_name); |
||||
// If no audience in the cluster, then no need to add call creds.
|
||||
if (metadata_value == nullptr) return absl::OkStatus(); |
||||
// If the entry is present but the wrong type, fail the RPC.
|
||||
if (metadata_value->type() != XdsGcpAuthnAudienceMetadataValue::Type()) { |
||||
return absl::UnavailableError(absl::StrCat( |
||||
"GCP authentication filter: audience metadata in wrong format for " |
||||
"cluster ", |
||||
cluster_name)); |
||||
} |
||||
// Get the call creds instance.
|
||||
auto creds = filter->GetCallCredentials( |
||||
DownCast<const XdsGcpAuthnAudienceMetadataValue*>(metadata_value)->url()); |
||||
// Add the call creds instance to the call.
|
||||
auto* arena = GetContext<Arena>(); |
||||
auto* security_ctx = DownCast<grpc_client_security_context*>( |
||||
arena->GetContext<SecurityContext>()); |
||||
if (security_ctx == nullptr) { |
||||
security_ctx = arena->New<grpc_client_security_context>(std::move(creds)); |
||||
arena->SetContext<SecurityContext>(security_ctx); |
||||
} else { |
||||
security_ctx->creds = std::move(creds); |
||||
} |
||||
return absl::OkStatus(); |
||||
} |
||||
|
||||
const grpc_channel_filter GcpAuthenticationFilter::kFilter = |
||||
MakePromiseBasedFilter<GcpAuthenticationFilter, FilterEndpoint::kClient, |
||||
0>(); |
||||
|
||||
absl::StatusOr<std::unique_ptr<GcpAuthenticationFilter>> |
||||
GcpAuthenticationFilter::Create(const ChannelArgs& args, |
||||
ChannelFilter::Args filter_args) { |
||||
auto* service_config = args.GetObject<ServiceConfig>(); |
||||
if (service_config == nullptr) { |
||||
return absl::InvalidArgumentError( |
||||
"gcp_auth: no service config in channel args"); |
||||
} |
||||
auto* config = static_cast<const GcpAuthenticationParsedConfig*>( |
||||
service_config->GetGlobalParsedConfig( |
||||
GcpAuthenticationServiceConfigParser::ParserIndex())); |
||||
if (config == nullptr) { |
||||
return absl::InvalidArgumentError("gcp_auth: parsed config not found"); |
||||
} |
||||
auto* filter_config = config->GetConfig(filter_args.instance_id()); |
||||
if (filter_config == nullptr) { |
||||
return absl::InvalidArgumentError( |
||||
"gcp_auth: filter instance ID not found in filter config"); |
||||
} |
||||
auto xds_config = args.GetObjectRef<XdsConfig>(); |
||||
if (xds_config == nullptr) { |
||||
return absl::InvalidArgumentError( |
||||
"gcp_auth: xds config not found in channel args"); |
||||
} |
||||
return std::make_unique<GcpAuthenticationFilter>(filter_config, |
||||
std::move(xds_config)); |
||||
} |
||||
|
||||
GcpAuthenticationFilter::GcpAuthenticationFilter( |
||||
const GcpAuthenticationParsedConfig::Config* filter_config, |
||||
RefCountedPtr<const XdsConfig> xds_config) |
||||
: filter_config_(filter_config), |
||||
xds_config_(std::move(xds_config)), |
||||
cache_(filter_config->cache_size) {} |
||||
|
||||
RefCountedPtr<grpc_call_credentials> |
||||
GcpAuthenticationFilter::GetCallCredentials(const std::string& audience) { |
||||
MutexLock lock(&mu_); |
||||
return cache_.GetOrInsert(audience, [](const std::string& audience) { |
||||
return MakeRefCounted<GcpServiceAccountIdentityCallCredentials>(audience); |
||||
}); |
||||
} |
||||
|
||||
void GcpAuthenticationFilterRegister(CoreConfiguration::Builder* builder) { |
||||
GcpAuthenticationServiceConfigParser::Register(builder); |
||||
} |
||||
|
||||
} // namespace grpc_core
|
@ -0,0 +1,82 @@ |
||||
//
|
||||
// Copyright 2024 gRPC authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
#ifndef GRPC_SRC_CORE_EXT_FILTERS_GCP_AUTHENTICATION_GCP_AUTHENTICATION_FILTER_H |
||||
#define GRPC_SRC_CORE_EXT_FILTERS_GCP_AUTHENTICATION_GCP_AUTHENTICATION_FILTER_H |
||||
|
||||
#include <memory> |
||||
#include <string> |
||||
|
||||
#include "absl/status/status.h" |
||||
#include "absl/status/statusor.h" |
||||
#include "absl/strings/string_view.h" |
||||
|
||||
#include "src/core/ext/filters/gcp_authentication/gcp_authentication_service_config_parser.h" |
||||
#include "src/core/lib/channel/channel_args.h" |
||||
#include "src/core/lib/channel/channel_fwd.h" |
||||
#include "src/core/lib/channel/promise_based_filter.h" |
||||
#include "src/core/lib/gprpp/ref_counted_ptr.h" |
||||
#include "src/core/lib/gprpp/sync.h" |
||||
#include "src/core/lib/security/credentials/credentials.h" |
||||
#include "src/core/lib/transport/transport.h" |
||||
#include "src/core/resolver/xds/xds_config.h" |
||||
#include "src/core/util/lru_cache.h" |
||||
|
||||
namespace grpc_core { |
||||
|
||||
// xDS GCP Authentication filter.
|
||||
// https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/gcp_authn_filter
|
||||
class GcpAuthenticationFilter |
||||
: public ImplementChannelFilter<GcpAuthenticationFilter> { |
||||
public: |
||||
static const grpc_channel_filter kFilter; |
||||
|
||||
static absl::string_view TypeName() { return "gcp_authentication_filter"; } |
||||
|
||||
static absl::StatusOr<std::unique_ptr<GcpAuthenticationFilter>> Create( |
||||
const ChannelArgs& args, ChannelFilter::Args filter_args); |
||||
|
||||
GcpAuthenticationFilter( |
||||
const GcpAuthenticationParsedConfig::Config* filter_config, |
||||
RefCountedPtr<const XdsConfig> xds_config); |
||||
|
||||
class Call { |
||||
public: |
||||
absl::Status OnClientInitialMetadata(ClientMetadata& /*md*/, |
||||
GcpAuthenticationFilter* filter); |
||||
static const NoInterceptor OnClientToServerMessage; |
||||
static const NoInterceptor OnClientToServerHalfClose; |
||||
static const NoInterceptor OnServerInitialMetadata; |
||||
static const NoInterceptor OnServerToClientMessage; |
||||
static const NoInterceptor OnServerTrailingMetadata; |
||||
static const NoInterceptor OnFinalize; |
||||
}; |
||||
|
||||
private: |
||||
RefCountedPtr<grpc_call_credentials> GetCallCredentials( |
||||
const std::string& audience); |
||||
|
||||
const GcpAuthenticationParsedConfig::Config* filter_config_; |
||||
const RefCountedPtr<const XdsConfig> xds_config_; |
||||
|
||||
Mutex mu_; |
||||
LruCache<std::string /*audience*/, RefCountedPtr<grpc_call_credentials>> |
||||
cache_ ABSL_GUARDED_BY(&mu_); |
||||
}; |
||||
|
||||
} // namespace grpc_core
|
||||
|
||||
#endif // GRPC_SRC_CORE_EXT_FILTERS_GCP_AUTHENTICATION_GCP_AUTHENTICATION_FILTER_H
|
@ -0,0 +1,81 @@ |
||||
//
|
||||
// Copyright 2024 gRPC authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
#include <grpc/support/port_platform.h> |
||||
|
||||
#include "src/core/ext/filters/gcp_authentication/gcp_authentication_service_config_parser.h" |
||||
|
||||
#include <vector> |
||||
|
||||
#include "absl/types/optional.h" |
||||
|
||||
#include "src/core/lib/channel/channel_args.h" |
||||
|
||||
namespace grpc_core { |
||||
|
||||
const JsonLoaderInterface* GcpAuthenticationParsedConfig::Config::JsonLoader( |
||||
const JsonArgs&) { |
||||
static const auto* loader = |
||||
JsonObjectLoader<Config>() |
||||
.Field("filter_instance_name", &Config::filter_instance_name) |
||||
.OptionalField("cache_size", &Config::cache_size) |
||||
.Finish(); |
||||
return loader; |
||||
} |
||||
|
||||
void GcpAuthenticationParsedConfig::Config::JsonPostLoad( |
||||
const Json&, const JsonArgs&, ValidationErrors* errors) { |
||||
if (cache_size == 0) { |
||||
ValidationErrors::ScopedField field(errors, ".cache_size"); |
||||
errors->AddError("must be non-zero"); |
||||
} |
||||
} |
||||
|
||||
const JsonLoaderInterface* GcpAuthenticationParsedConfig::JsonLoader( |
||||
const JsonArgs&) { |
||||
static const auto* loader = |
||||
JsonObjectLoader<GcpAuthenticationParsedConfig>() |
||||
.OptionalField("gcp_authentication", |
||||
&GcpAuthenticationParsedConfig::configs_) |
||||
.Finish(); |
||||
return loader; |
||||
} |
||||
|
||||
std::unique_ptr<ServiceConfigParser::ParsedConfig> |
||||
GcpAuthenticationServiceConfigParser::ParseGlobalParams( |
||||
const ChannelArgs& args, const Json& json, ValidationErrors* errors) { |
||||
// Only parse config if the following channel arg is enabled.
|
||||
if (!args.GetBool(GRPC_ARG_PARSE_GCP_AUTHENTICATION_METHOD_CONFIG) |
||||
.value_or(false)) { |
||||
return nullptr; |
||||
} |
||||
// Parse config from json.
|
||||
return LoadFromJson<std::unique_ptr<GcpAuthenticationParsedConfig>>( |
||||
json, JsonArgs(), errors); |
||||
} |
||||
|
||||
void GcpAuthenticationServiceConfigParser::Register( |
||||
CoreConfiguration::Builder* builder) { |
||||
builder->service_config_parser()->RegisterParser( |
||||
std::make_unique<GcpAuthenticationServiceConfigParser>()); |
||||
} |
||||
|
||||
size_t GcpAuthenticationServiceConfigParser::ParserIndex() { |
||||
return CoreConfiguration::Get().service_config_parser().GetParserIndex( |
||||
parser_name()); |
||||
} |
||||
|
||||
} // namespace grpc_core
|
@ -0,0 +1,87 @@ |
||||
//
|
||||
// Copyright 2024 gRPC authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
#ifndef GRPC_SRC_CORE_EXT_FILTERS_GCP_AUTHENTICATION_GCP_AUTHENTICATION_SERVICE_CONFIG_PARSER_H |
||||
#define GRPC_SRC_CORE_EXT_FILTERS_GCP_AUTHENTICATION_GCP_AUTHENTICATION_SERVICE_CONFIG_PARSER_H |
||||
|
||||
#include <stddef.h> |
||||
|
||||
#include <memory> |
||||
#include <string> |
||||
#include <vector> |
||||
|
||||
#include "absl/strings/string_view.h" |
||||
#include "absl/types/optional.h" |
||||
|
||||
#include "src/core/lib/channel/channel_args.h" |
||||
#include "src/core/lib/config/core_configuration.h" |
||||
#include "src/core/lib/gprpp/validation_errors.h" |
||||
#include "src/core/service_config/service_config_parser.h" |
||||
#include "src/core/util/json/json.h" |
||||
#include "src/core/util/json/json_args.h" |
||||
#include "src/core/util/json/json_object_loader.h" |
||||
|
||||
// Channel arg key for enabling parsing fault injection via method config.
|
||||
#define GRPC_ARG_PARSE_GCP_AUTHENTICATION_METHOD_CONFIG \ |
||||
"grpc.internal.parse_gcp_authentication_method_config" |
||||
|
||||
namespace grpc_core { |
||||
|
||||
class GcpAuthenticationParsedConfig : public ServiceConfigParser::ParsedConfig { |
||||
public: |
||||
struct Config { |
||||
std::string filter_instance_name; |
||||
uint64_t cache_size = 10; |
||||
|
||||
static const JsonLoaderInterface* JsonLoader(const JsonArgs&); |
||||
void JsonPostLoad(const Json&, const JsonArgs&, ValidationErrors* errors); |
||||
}; |
||||
|
||||
// Returns the config at the specified index. There might be multiple
|
||||
// GCP auth filters in the list of HTTP filters at the same time.
|
||||
// The order of the list is stable, and an index is used to keep track of
|
||||
// their relative positions. Each filter instance uses this method to
|
||||
// access the appropriate parsed config for that instance.
|
||||
const Config* GetConfig(size_t index) const { |
||||
if (index >= configs_.size()) return nullptr; |
||||
return &configs_[index]; |
||||
} |
||||
|
||||
static const JsonLoaderInterface* JsonLoader(const JsonArgs&); |
||||
|
||||
private: |
||||
std::vector<Config> configs_; |
||||
}; |
||||
|
||||
class GcpAuthenticationServiceConfigParser final |
||||
: public ServiceConfigParser::Parser { |
||||
public: |
||||
absl::string_view name() const override { return parser_name(); } |
||||
std::unique_ptr<ServiceConfigParser::ParsedConfig> ParseGlobalParams( |
||||
const ChannelArgs& args, const Json& json, |
||||
ValidationErrors* errors) override; |
||||
// Returns the parser index for the parser.
|
||||
static size_t ParserIndex(); |
||||
// Registers the parser.
|
||||
static void Register(CoreConfiguration::Builder* builder); |
||||
|
||||
private: |
||||
static absl::string_view parser_name() { return "gcp_auth"; } |
||||
}; |
||||
|
||||
} // namespace grpc_core
|
||||
|
||||
#endif // GRPC_SRC_CORE_EXT_FILTERS_GCP_AUTHENTICATION_GCP_AUTHENTICATION_SERVICE_CONFIG_PARSER_H
|
@ -0,0 +1,104 @@ |
||||
//
|
||||
// Copyright 2024 gRPC authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
#ifndef GRPC_SRC_CORE_UTIL_LRU_CACHE_H |
||||
#define GRPC_SRC_CORE_UTIL_LRU_CACHE_H |
||||
|
||||
#include <list> |
||||
#include <tuple> |
||||
#include <utility> |
||||
|
||||
#include "absl/container/flat_hash_map.h" |
||||
#include "absl/functional/any_invocable.h" |
||||
#include "absl/log/check.h" |
||||
#include "absl/types/optional.h" |
||||
|
||||
namespace grpc_core { |
||||
|
||||
// A simple LRU cache. Retains at most max_size entries.
|
||||
// Caller is responsible for synchronization.
|
||||
// TODO(roth): Support heterogenous lookups.
|
||||
template <typename Key, typename Value> |
||||
class LruCache { |
||||
public: |
||||
explicit LruCache(size_t max_size) : max_size_(max_size) { |
||||
CHECK_GT(max_size, 0UL); |
||||
} |
||||
|
||||
// Returns the value for key, or nullopt if not present.
|
||||
absl::optional<Value> Get(Key key); |
||||
|
||||
// If key is present in the cache, returns the corresponding value.
|
||||
// Otherwise, inserts a new entry in the map, calling create() to
|
||||
// construct the new value. If inserting a new entry causes the cache
|
||||
// to be too large, removes the least recently used entry.
|
||||
Value GetOrInsert(Key key, absl::AnyInvocable<Value(const Key&)> create); |
||||
|
||||
private: |
||||
struct CacheEntry { |
||||
Value value; |
||||
typename std::list<Key>::iterator lru_iterator; |
||||
|
||||
explicit CacheEntry(Value v) : value(std::move(v)) {} |
||||
}; |
||||
|
||||
const size_t max_size_; |
||||
absl::flat_hash_map<Key, CacheEntry> cache_; |
||||
std::list<Key> lru_list_; |
||||
}; |
||||
|
||||
//
|
||||
// implementation -- no user-serviceable parts below
|
||||
//
|
||||
|
||||
template <typename Key, typename Value> |
||||
absl::optional<Value> LruCache<Key, Value>::Get(Key key) { |
||||
auto it = cache_.find(key); |
||||
if (it == cache_.end()) return absl::nullopt; |
||||
// Found the entry. Move the entry to the end of the LRU list.
|
||||
auto new_lru_it = lru_list_.insert(lru_list_.end(), *it->second.lru_iterator); |
||||
lru_list_.erase(it->second.lru_iterator); |
||||
it->second.lru_iterator = new_lru_it; |
||||
return it->second.value; |
||||
} |
||||
|
||||
template <typename Key, typename Value> |
||||
Value LruCache<Key, Value>::GetOrInsert( |
||||
Key key, absl::AnyInvocable<Value(const Key&)> create) { |
||||
auto value = Get(key); |
||||
if (value.has_value()) return std::move(*value); |
||||
// Entry not found. We'll need to insert a new entry.
|
||||
// If the cache is at max size, remove the least recently used entry.
|
||||
if (cache_.size() == max_size_) { |
||||
auto lru_it = lru_list_.begin(); |
||||
CHECK(lru_it != lru_list_.end()); |
||||
auto cache_it = cache_.find(*lru_it); |
||||
CHECK(cache_it != cache_.end()); |
||||
cache_.erase(cache_it); |
||||
lru_list_.pop_front(); |
||||
} |
||||
// Create a new entry, insert it, and return it.
|
||||
auto it = cache_ |
||||
.emplace(std::piecewise_construct, std::forward_as_tuple(key), |
||||
std::forward_as_tuple(create(key))) |
||||
.first; |
||||
it->second.lru_iterator = lru_list_.insert(lru_list_.end(), std::move(key)); |
||||
return it->second.value; |
||||
} |
||||
|
||||
} // namespace grpc_core
|
||||
|
||||
#endif // GRPC_SRC_CORE_UTIL_LRU_CACHE_H
|
@ -0,0 +1,142 @@ |
||||
//
|
||||
// Copyright 2024 gRPC authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
#include "src/core/xds/grpc/xds_http_gcp_authn_filter.h" |
||||
|
||||
#include <string> |
||||
#include <utility> |
||||
|
||||
#include "absl/status/statusor.h" |
||||
#include "absl/strings/string_view.h" |
||||
#include "absl/types/variant.h" |
||||
#include "envoy/extensions/filters/http/gcp_authn/v3/gcp_authn.upb.h" |
||||
#include "envoy/extensions/filters/http/gcp_authn/v3/gcp_authn.upbdefs.h" |
||||
|
||||
#include <grpc/support/json.h> |
||||
|
||||
#include "src/core/ext/filters/gcp_authentication/gcp_authentication_filter.h" |
||||
#include "src/core/ext/filters/gcp_authentication/gcp_authentication_service_config_parser.h" |
||||
#include "src/core/lib/channel/channel_args.h" |
||||
#include "src/core/lib/gprpp/validation_errors.h" |
||||
#include "src/core/util/json/json.h" |
||||
#include "src/core/util/json/json_writer.h" |
||||
#include "src/core/xds/grpc/xds_common_types.h" |
||||
#include "src/core/xds/grpc/xds_common_types_parser.h" |
||||
#include "src/core/xds/grpc/xds_http_filter.h" |
||||
|
||||
namespace grpc_core { |
||||
|
||||
absl::string_view XdsHttpGcpAuthnFilter::ConfigProtoName() const { |
||||
return "envoy.extensions.filters.http.gcp_authn.v3.GcpAuthnFilterConfig"; |
||||
} |
||||
|
||||
absl::string_view XdsHttpGcpAuthnFilter::OverrideConfigProtoName() const { |
||||
return ""; |
||||
} |
||||
|
||||
void XdsHttpGcpAuthnFilter::PopulateSymtab(upb_DefPool* symtab) const { |
||||
envoy_extensions_filters_http_gcp_authn_v3_GcpAuthnFilterConfig_getmsgdef( |
||||
symtab); |
||||
} |
||||
|
||||
namespace { |
||||
|
||||
Json::Object ValidateFilterConfig( |
||||
absl::string_view instance_name, |
||||
const envoy_extensions_filters_http_gcp_authn_v3_GcpAuthnFilterConfig* |
||||
gcp_auth, |
||||
ValidationErrors* errors) { |
||||
Json::Object config = { |
||||
{"filter_instance_name", Json::FromString(std::string(instance_name))}}; |
||||
const auto* cache_config = |
||||
envoy_extensions_filters_http_gcp_authn_v3_GcpAuthnFilterConfig_cache_config( |
||||
gcp_auth); |
||||
if (cache_config == nullptr) return config; |
||||
uint64_t cache_size = |
||||
ParseUInt64Value( |
||||
envoy_extensions_filters_http_gcp_authn_v3_TokenCacheConfig_cache_size( |
||||
cache_config)) |
||||
.value_or(10); |
||||
if (cache_size == 0 || cache_size >= INT64_MAX) { |
||||
ValidationErrors::ScopedField field(errors, ".cache_config.cache_size"); |
||||
errors->AddError("must be in the range (0, INT64_MAX)"); |
||||
} |
||||
config["cache_size"] = Json::FromNumber(cache_size); |
||||
return config; |
||||
} |
||||
|
||||
} // namespace
|
||||
|
||||
absl::optional<XdsHttpFilterImpl::FilterConfig> |
||||
XdsHttpGcpAuthnFilter::GenerateFilterConfig( |
||||
absl::string_view instance_name, |
||||
const XdsResourceType::DecodeContext& context, XdsExtension extension, |
||||
ValidationErrors* errors) const { |
||||
absl::string_view* serialized_filter_config = |
||||
absl::get_if<absl::string_view>(&extension.value); |
||||
if (serialized_filter_config == nullptr) { |
||||
errors->AddError("could not parse GCP auth filter config"); |
||||
return absl::nullopt; |
||||
} |
||||
auto* gcp_auth = |
||||
envoy_extensions_filters_http_gcp_authn_v3_GcpAuthnFilterConfig_parse( |
||||
serialized_filter_config->data(), serialized_filter_config->size(), |
||||
context.arena); |
||||
if (gcp_auth == nullptr) { |
||||
errors->AddError("could not parse GCP auth filter config"); |
||||
return absl::nullopt; |
||||
} |
||||
return FilterConfig{ConfigProtoName(), Json::FromObject(ValidateFilterConfig( |
||||
instance_name, gcp_auth, errors))}; |
||||
} |
||||
|
||||
absl::optional<XdsHttpFilterImpl::FilterConfig> |
||||
XdsHttpGcpAuthnFilter::GenerateFilterConfigOverride( |
||||
absl::string_view /*instance_name*/, |
||||
const XdsResourceType::DecodeContext& /*context*/, |
||||
XdsExtension /*extension*/, ValidationErrors* errors) const { |
||||
errors->AddError("GCP auth filter does not support config override"); |
||||
return absl::nullopt; |
||||
} |
||||
|
||||
void XdsHttpGcpAuthnFilter::AddFilter(InterceptionChainBuilder& builder) const { |
||||
builder.Add<GcpAuthenticationFilter>(); |
||||
} |
||||
|
||||
const grpc_channel_filter* XdsHttpGcpAuthnFilter::channel_filter() const { |
||||
return &GcpAuthenticationFilter::kFilter; |
||||
} |
||||
|
||||
ChannelArgs XdsHttpGcpAuthnFilter::ModifyChannelArgs( |
||||
const ChannelArgs& args) const { |
||||
return args.Set(GRPC_ARG_PARSE_GCP_AUTHENTICATION_METHOD_CONFIG, 1); |
||||
} |
||||
|
||||
absl::StatusOr<XdsHttpFilterImpl::ServiceConfigJsonEntry> |
||||
XdsHttpGcpAuthnFilter::GenerateMethodConfig( |
||||
const FilterConfig& /*hcm_filter_config*/, |
||||
const FilterConfig* /*filter_config_override*/) const { |
||||
return ServiceConfigJsonEntry{"", ""}; |
||||
} |
||||
|
||||
absl::StatusOr<XdsHttpFilterImpl::ServiceConfigJsonEntry> |
||||
XdsHttpGcpAuthnFilter::GenerateServiceConfig( |
||||
const FilterConfig& hcm_filter_config) const { |
||||
return ServiceConfigJsonEntry{"gcp_authentication", |
||||
JsonDump(hcm_filter_config.config)}; |
||||
} |
||||
|
||||
} // namespace grpc_core
|
@ -0,0 +1,61 @@ |
||||
//
|
||||
// Copyright 2024 gRPC authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
#ifndef GRPC_SRC_CORE_XDS_GRPC_XDS_HTTP_GCP_AUTHN_FILTER_H |
||||
#define GRPC_SRC_CORE_XDS_GRPC_XDS_HTTP_GCP_AUTHN_FILTER_H |
||||
|
||||
#include "absl/status/statusor.h" |
||||
#include "absl/strings/string_view.h" |
||||
#include "absl/types/optional.h" |
||||
#include "upb/reflection/def.h" |
||||
|
||||
#include "src/core/lib/channel/channel_args.h" |
||||
#include "src/core/lib/channel/channel_fwd.h" |
||||
#include "src/core/lib/gprpp/validation_errors.h" |
||||
#include "src/core/xds/grpc/xds_common_types.h" |
||||
#include "src/core/xds/grpc/xds_http_filter.h" |
||||
#include "src/core/xds/xds_client/xds_resource_type.h" |
||||
|
||||
namespace grpc_core { |
||||
|
||||
class XdsHttpGcpAuthnFilter final : public XdsHttpFilterImpl { |
||||
public: |
||||
absl::string_view ConfigProtoName() const override; |
||||
absl::string_view OverrideConfigProtoName() const override; |
||||
void PopulateSymtab(upb_DefPool* symtab) const override; |
||||
absl::optional<FilterConfig> GenerateFilterConfig( |
||||
absl::string_view instance_name, |
||||
const XdsResourceType::DecodeContext& context, XdsExtension extension, |
||||
ValidationErrors* errors) const override; |
||||
absl::optional<FilterConfig> GenerateFilterConfigOverride( |
||||
absl::string_view instance_name, |
||||
const XdsResourceType::DecodeContext& context, XdsExtension extension, |
||||
ValidationErrors* errors) const override; |
||||
void AddFilter(InterceptionChainBuilder& builder) const override; |
||||
const grpc_channel_filter* channel_filter() const override; |
||||
ChannelArgs ModifyChannelArgs(const ChannelArgs& args) const override; |
||||
absl::StatusOr<ServiceConfigJsonEntry> GenerateMethodConfig( |
||||
const FilterConfig& hcm_filter_config, |
||||
const FilterConfig* filter_config_override) const override; |
||||
absl::StatusOr<ServiceConfigJsonEntry> GenerateServiceConfig( |
||||
const FilterConfig& hcm_filter_config) const override; |
||||
bool IsSupportedOnClients() const override { return true; } |
||||
bool IsSupportedOnServers() const override { return false; } |
||||
}; |
||||
|
||||
} // namespace grpc_core
|
||||
|
||||
#endif // GRPC_SRC_CORE_XDS_GRPC_XDS_HTTP_GCP_AUTHN_FILTER_H
|
@ -0,0 +1,386 @@ |
||||
// Copyright 2024 gRPC authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#include "src/core/ext/filters/gcp_authentication/gcp_authentication_filter.h" |
||||
|
||||
#include <memory> |
||||
#include <utility> |
||||
|
||||
#include "absl/status/status.h" |
||||
#include "absl/status/statusor.h" |
||||
#include "absl/strings/string_view.h" |
||||
#include "gmock/gmock.h" |
||||
#include "gtest/gtest.h" |
||||
|
||||
#include "src/core/lib/channel/channel_args.h" |
||||
#include "src/core/lib/channel/promise_based_filter.h" |
||||
#include "src/core/lib/gprpp/ref_counted_ptr.h" |
||||
#include "src/core/lib/gprpp/unique_type_name.h" |
||||
#include "src/core/lib/security/context/security_context.h" |
||||
#include "src/core/lib/security/credentials/credentials.h" |
||||
#include "src/core/lib/security/credentials/gcp_service_account_identity/gcp_service_account_identity_credentials.h" |
||||
#include "src/core/resolver/xds/xds_config.h" |
||||
#include "src/core/resolver/xds/xds_resolver_attributes.h" |
||||
#include "src/core/service_config/service_config_call_data.h" |
||||
#include "src/core/service_config/service_config_impl.h" |
||||
#include "test/core/filters/filter_test.h" |
||||
|
||||
namespace grpc_core { |
||||
namespace { |
||||
|
||||
class GcpAuthenticationFilterTest : public FilterTest<GcpAuthenticationFilter> { |
||||
protected: |
||||
static RefCountedPtr<ServiceConfig> MakeServiceConfig( |
||||
absl::string_view service_config_json) { |
||||
auto service_config = ServiceConfigImpl::Create( |
||||
ChannelArgs().Set(GRPC_ARG_PARSE_GCP_AUTHENTICATION_METHOD_CONFIG, |
||||
true), |
||||
service_config_json); |
||||
CHECK(service_config.ok()) << service_config.status(); |
||||
return *service_config; |
||||
} |
||||
|
||||
static RefCountedPtr<const XdsConfig> MakeXdsConfig( |
||||
absl::string_view cluster, absl::string_view filter_instance_name, |
||||
std::unique_ptr<XdsMetadataValue> audience_metadata) { |
||||
auto xds_config = MakeRefCounted<XdsConfig>(); |
||||
if (!cluster.empty()) { |
||||
auto cluster_resource = std::make_shared<XdsClusterResource>(); |
||||
if (audience_metadata != nullptr) { |
||||
cluster_resource->metadata.Insert(filter_instance_name, |
||||
std::move(audience_metadata)); |
||||
} |
||||
xds_config->clusters[cluster].emplace(std::move(cluster_resource), |
||||
nullptr, ""); |
||||
} |
||||
return xds_config; |
||||
} |
||||
|
||||
static RefCountedPtr<const XdsConfig> MakeXdsConfigWithCluster( |
||||
absl::string_view cluster, |
||||
absl::StatusOr<XdsConfig::ClusterConfig> cluster_config) { |
||||
auto xds_config = MakeRefCounted<XdsConfig>(); |
||||
xds_config->clusters[cluster] = std::move(cluster_config); |
||||
return xds_config; |
||||
} |
||||
|
||||
ChannelArgs MakeChannelArgs( |
||||
absl::string_view service_config_json, absl::string_view cluster, |
||||
absl::string_view filter_instance_name, |
||||
std::unique_ptr<XdsMetadataValue> audience_metadata) { |
||||
auto service_config = MakeServiceConfig(service_config_json); |
||||
auto xds_config = MakeXdsConfig(cluster, filter_instance_name, |
||||
std::move(audience_metadata)); |
||||
return ChannelArgs() |
||||
.SetObject(std::move(service_config)) |
||||
.SetObject(std::move(xds_config)); |
||||
} |
||||
|
||||
static RefCountedPtr<grpc_call_credentials> GetCallCreds(const Call& call) { |
||||
auto* security_ctx = DownCast<grpc_client_security_context*>( |
||||
call.arena()->GetContext<SecurityContext>()); |
||||
if (security_ctx == nullptr) return nullptr; |
||||
return security_ctx->creds; |
||||
} |
||||
}; |
||||
|
||||
TEST_F(GcpAuthenticationFilterTest, CreateSucceeds) { |
||||
constexpr absl::string_view kClusterName = "foo"; |
||||
constexpr absl::string_view kFilterInstanceName = "gcp_authn_filter"; |
||||
constexpr absl::string_view kServiceConfigJson = |
||||
"{\n" |
||||
" \"gcp_authentication\": [\n" |
||||
" {\"filter_instance_name\": \"gcp_authn_filter\"}\n" |
||||
" ]\n" |
||||
"}"; |
||||
auto channel_args = MakeChannelArgs(kServiceConfigJson, kClusterName, |
||||
kFilterInstanceName, nullptr); |
||||
auto filter = GcpAuthenticationFilter::Create( |
||||
channel_args, ChannelFilter::Args(/*instance_id=*/0)); |
||||
EXPECT_TRUE(filter.ok()) << filter.status(); |
||||
} |
||||
|
||||
TEST_F(GcpAuthenticationFilterTest, CreateFailsWithoutServiceConfig) { |
||||
constexpr absl::string_view kClusterName = "foo"; |
||||
constexpr absl::string_view kFilterInstanceName = "gcp_authn_filter"; |
||||
auto channel_args = ChannelArgs().SetObject( |
||||
MakeXdsConfig(kClusterName, kFilterInstanceName, nullptr)); |
||||
auto filter = GcpAuthenticationFilter::Create( |
||||
channel_args, ChannelFilter::Args(/*instance_id=*/0)); |
||||
EXPECT_EQ(filter.status(), |
||||
absl::InvalidArgumentError( |
||||
"gcp_auth: no service config in channel args")); |
||||
} |
||||
|
||||
TEST_F(GcpAuthenticationFilterTest, |
||||
CreateFailsFilterConfigMissingFromServiceConfig) { |
||||
constexpr absl::string_view kClusterName = "foo"; |
||||
constexpr absl::string_view kFilterInstanceName = "gcp_authn_filter"; |
||||
constexpr absl::string_view kServiceConfigJson = "{}"; |
||||
auto channel_args = MakeChannelArgs(kServiceConfigJson, kClusterName, |
||||
kFilterInstanceName, nullptr); |
||||
auto filter = GcpAuthenticationFilter::Create( |
||||
channel_args, ChannelFilter::Args(/*instance_id=*/0)); |
||||
EXPECT_EQ(filter.status(), |
||||
absl::InvalidArgumentError( |
||||
"gcp_auth: filter instance ID not found in filter config")); |
||||
} |
||||
|
||||
TEST_F(GcpAuthenticationFilterTest, CreateFailsXdsConfigNotFoundInChannelArgs) { |
||||
constexpr absl::string_view kServiceConfigJson = |
||||
"{\n" |
||||
" \"gcp_authentication\": [\n" |
||||
" {\"filter_instance_name\": \"gcp_authn_filter\"}\n" |
||||
" ]\n" |
||||
"}"; |
||||
auto channel_args = |
||||
ChannelArgs().SetObject(MakeServiceConfig(kServiceConfigJson)); |
||||
auto filter = GcpAuthenticationFilter::Create( |
||||
channel_args, ChannelFilter::Args(/*instance_id=*/0)); |
||||
EXPECT_EQ(filter.status(), |
||||
absl::InvalidArgumentError( |
||||
"gcp_auth: xds config not found in channel args")); |
||||
} |
||||
|
||||
TEST_F(GcpAuthenticationFilterTest, FailsCallIfNoXdsClusterAttribute) { |
||||
constexpr absl::string_view kClusterName = "foo"; |
||||
constexpr absl::string_view kFilterInstanceName = "gcp_authn_filter"; |
||||
constexpr absl::string_view kServiceConfigJson = |
||||
"{\n" |
||||
" \"gcp_authentication\": [\n" |
||||
" {\"filter_instance_name\": \"gcp_authn_filter\"}\n" |
||||
" ]\n" |
||||
"}"; |
||||
auto channel_args = MakeChannelArgs(kServiceConfigJson, kClusterName, |
||||
kFilterInstanceName, nullptr); |
||||
Call call(MakeChannel(channel_args).value()); |
||||
call.arena()->New<ServiceConfigCallData>(call.arena()); |
||||
call.Start(call.NewClientMetadata()); |
||||
EXPECT_EVENT(Finished( |
||||
&call, |
||||
HasMetadataResult(absl::InternalError( |
||||
"GCP authentication filter: call has no xDS cluster attribute")))); |
||||
Step(); |
||||
// Call creds were not set.
|
||||
EXPECT_EQ(GetCallCreds(call), nullptr); |
||||
} |
||||
|
||||
TEST_F(GcpAuthenticationFilterTest, NoOpIfClusterAttributeHasWrongPrefix) { |
||||
constexpr absl::string_view kClusterName = "foo"; |
||||
constexpr absl::string_view kFilterInstanceName = "gcp_authn_filter"; |
||||
constexpr absl::string_view kServiceConfigJson = |
||||
"{\n" |
||||
" \"gcp_authentication\": [\n" |
||||
" {\"filter_instance_name\": \"gcp_authn_filter\"}\n" |
||||
" ]\n" |
||||
"}"; |
||||
constexpr absl::string_view kAudience = "bar"; |
||||
auto channel_args = MakeChannelArgs( |
||||
kServiceConfigJson, kClusterName, kFilterInstanceName, |
||||
std::make_unique<XdsGcpAuthnAudienceMetadataValue>(kAudience)); |
||||
Call call(MakeChannel(channel_args).value()); |
||||
auto* service_config_call_data = |
||||
call.arena()->New<ServiceConfigCallData>(call.arena()); |
||||
XdsClusterAttribute xds_cluster_attribute(kClusterName); |
||||
service_config_call_data->SetCallAttribute(&xds_cluster_attribute); |
||||
EXPECT_EVENT(Started(&call, ::testing::_)); |
||||
call.Start(call.NewClientMetadata()); |
||||
call.FinishNextFilter(call.NewServerMetadata({{"grpc-status", "0"}})); |
||||
EXPECT_EVENT(Finished(&call, HasMetadataResult(absl::OkStatus()))); |
||||
Step(); |
||||
// Call creds were not set.
|
||||
EXPECT_EQ(GetCallCreds(call), nullptr); |
||||
} |
||||
|
||||
TEST_F(GcpAuthenticationFilterTest, FailsCallIfClusterNotPresentInXdsConfig) { |
||||
constexpr absl::string_view kClusterName = "foo"; |
||||
constexpr absl::string_view kServiceConfigJson = |
||||
"{\n" |
||||
" \"gcp_authentication\": [\n" |
||||
" {\"filter_instance_name\": \"gcp_authn_filter\"}\n" |
||||
" ]\n" |
||||
"}"; |
||||
auto channel_args = MakeChannelArgs(kServiceConfigJson, /*cluster=*/"", |
||||
/*filter_instance_name=*/"", nullptr); |
||||
Call call(MakeChannel(channel_args).value()); |
||||
auto* service_config_call_data = |
||||
call.arena()->New<ServiceConfigCallData>(call.arena()); |
||||
std::string cluster_name_with_prefix = absl::StrCat("cluster:", kClusterName); |
||||
XdsClusterAttribute xds_cluster_attribute(cluster_name_with_prefix); |
||||
service_config_call_data->SetCallAttribute(&xds_cluster_attribute); |
||||
call.Start(call.NewClientMetadata()); |
||||
EXPECT_EVENT( |
||||
Finished(&call, HasMetadataResult(absl::InternalError(absl::StrCat( |
||||
"GCP authentication filter: xDS cluster ", |
||||
kClusterName, " not found in XdsConfig"))))); |
||||
Step(); |
||||
// Call creds were not set.
|
||||
EXPECT_EQ(GetCallCreds(call), nullptr); |
||||
} |
||||
|
||||
TEST_F(GcpAuthenticationFilterTest, FailsCallIfClusterNotOkayInXdsConfig) { |
||||
constexpr absl::string_view kClusterName = "foo"; |
||||
constexpr absl::string_view kServiceConfigJson = |
||||
"{\n" |
||||
" \"gcp_authentication\": [\n" |
||||
" {\"filter_instance_name\": \"gcp_authn_filter\"}\n" |
||||
" ]\n" |
||||
"}"; |
||||
auto channel_args = ChannelArgs() |
||||
.SetObject(MakeServiceConfig(kServiceConfigJson)) |
||||
.SetObject(MakeXdsConfigWithCluster( |
||||
kClusterName, absl::UnavailableError("nope"))); |
||||
Call call(MakeChannel(channel_args).value()); |
||||
auto* service_config_call_data = |
||||
call.arena()->New<ServiceConfigCallData>(call.arena()); |
||||
std::string cluster_name_with_prefix = absl::StrCat("cluster:", kClusterName); |
||||
XdsClusterAttribute xds_cluster_attribute(cluster_name_with_prefix); |
||||
service_config_call_data->SetCallAttribute(&xds_cluster_attribute); |
||||
call.Start(call.NewClientMetadata()); |
||||
EXPECT_EVENT(Finished( |
||||
&call, HasMetadataResult(absl::UnavailableError(absl::StrCat( |
||||
"GCP authentication filter: CDS resource unavailable for ", |
||||
kClusterName))))); |
||||
Step(); |
||||
// Call creds were not set.
|
||||
EXPECT_EQ(GetCallCreds(call), nullptr); |
||||
} |
||||
|
||||
TEST_F(GcpAuthenticationFilterTest, |
||||
FailsCallIfClusterResourceMissingInXdsConfig) { |
||||
constexpr absl::string_view kClusterName = "foo"; |
||||
constexpr absl::string_view kServiceConfigJson = |
||||
"{\n" |
||||
" \"gcp_authentication\": [\n" |
||||
" {\"filter_instance_name\": \"gcp_authn_filter\"}\n" |
||||
" ]\n" |
||||
"}"; |
||||
auto channel_args = |
||||
ChannelArgs() |
||||
.SetObject(MakeServiceConfig(kServiceConfigJson)) |
||||
.SetObject(MakeXdsConfigWithCluster( |
||||
kClusterName, XdsConfig::ClusterConfig(nullptr, nullptr, ""))); |
||||
Call call(MakeChannel(channel_args).value()); |
||||
auto* service_config_call_data = |
||||
call.arena()->New<ServiceConfigCallData>(call.arena()); |
||||
std::string cluster_name_with_prefix = absl::StrCat("cluster:", kClusterName); |
||||
XdsClusterAttribute xds_cluster_attribute(cluster_name_with_prefix); |
||||
service_config_call_data->SetCallAttribute(&xds_cluster_attribute); |
||||
call.Start(call.NewClientMetadata()); |
||||
EXPECT_EVENT(Finished( |
||||
&call, |
||||
HasMetadataResult(absl::InternalError(absl::StrCat( |
||||
"GCP authentication filter: CDS resource not present for cluster ", |
||||
kClusterName))))); |
||||
Step(); |
||||
// Call creds were not set.
|
||||
EXPECT_EQ(GetCallCreds(call), nullptr); |
||||
} |
||||
|
||||
TEST_F(GcpAuthenticationFilterTest, NoOpIfClusterHasNoAudience) { |
||||
constexpr absl::string_view kClusterName = "foo"; |
||||
constexpr absl::string_view kFilterInstanceName = "gcp_authn_filter"; |
||||
constexpr absl::string_view kServiceConfigJson = |
||||
"{\n" |
||||
" \"gcp_authentication\": [\n" |
||||
" {\"filter_instance_name\": \"gcp_authn_filter\"}\n" |
||||
" ]\n" |
||||
"}"; |
||||
auto channel_args = MakeChannelArgs(kServiceConfigJson, kClusterName, |
||||
kFilterInstanceName, nullptr); |
||||
Call call(MakeChannel(channel_args).value()); |
||||
auto* service_config_call_data = |
||||
call.arena()->New<ServiceConfigCallData>(call.arena()); |
||||
std::string cluster_name_with_prefix = absl::StrCat("cluster:", kClusterName); |
||||
XdsClusterAttribute xds_cluster_attribute(cluster_name_with_prefix); |
||||
service_config_call_data->SetCallAttribute(&xds_cluster_attribute); |
||||
EXPECT_EVENT(Started(&call, ::testing::_)); |
||||
call.Start(call.NewClientMetadata()); |
||||
call.FinishNextFilter(call.NewServerMetadata({{"grpc-status", "0"}})); |
||||
EXPECT_EVENT(Finished(&call, HasMetadataResult(absl::OkStatus()))); |
||||
Step(); |
||||
// Call creds were not set.
|
||||
EXPECT_EQ(GetCallCreds(call), nullptr); |
||||
} |
||||
|
||||
TEST_F(GcpAuthenticationFilterTest, FailsCallIfAudienceMetadataWrongType) { |
||||
constexpr absl::string_view kClusterName = "foo"; |
||||
constexpr absl::string_view kFilterInstanceName = "gcp_authn_filter"; |
||||
constexpr absl::string_view kServiceConfigJson = |
||||
"{\n" |
||||
" \"gcp_authentication\": [\n" |
||||
" {\"filter_instance_name\": \"gcp_authn_filter\"}\n" |
||||
" ]\n" |
||||
"}"; |
||||
auto channel_args = |
||||
MakeChannelArgs(kServiceConfigJson, kClusterName, kFilterInstanceName, |
||||
std::make_unique<XdsStructMetadataValue>(Json())); |
||||
Call call(MakeChannel(channel_args).value()); |
||||
auto* service_config_call_data = |
||||
call.arena()->New<ServiceConfigCallData>(call.arena()); |
||||
std::string cluster_name_with_prefix = absl::StrCat("cluster:", kClusterName); |
||||
XdsClusterAttribute xds_cluster_attribute(cluster_name_with_prefix); |
||||
service_config_call_data->SetCallAttribute(&xds_cluster_attribute); |
||||
call.Start(call.NewClientMetadata()); |
||||
EXPECT_EVENT(Finished( |
||||
&call, HasMetadataResult(absl::UnavailableError(absl::StrCat( |
||||
"GCP authentication filter: audience metadata in wrong format " |
||||
"for cluster ", |
||||
kClusterName))))); |
||||
Step(); |
||||
// Call creds were not set.
|
||||
EXPECT_EQ(GetCallCreds(call), nullptr); |
||||
} |
||||
|
||||
TEST_F(GcpAuthenticationFilterTest, SetsCallCredsIfClusterHasAudience) { |
||||
constexpr absl::string_view kClusterName = "foo"; |
||||
constexpr absl::string_view kFilterInstanceName = "gcp_authn_filter"; |
||||
constexpr absl::string_view kServiceConfigJson = |
||||
"{\n" |
||||
" \"gcp_authentication\": [\n" |
||||
" {\"filter_instance_name\": \"gcp_authn_filter\"}\n" |
||||
" ]\n" |
||||
"}"; |
||||
constexpr absl::string_view kAudience = "bar"; |
||||
auto channel_args = MakeChannelArgs( |
||||
kServiceConfigJson, kClusterName, kFilterInstanceName, |
||||
std::make_unique<XdsGcpAuthnAudienceMetadataValue>(kAudience)); |
||||
Call call(MakeChannel(channel_args).value()); |
||||
auto* service_config_call_data = |
||||
call.arena()->New<ServiceConfigCallData>(call.arena()); |
||||
std::string cluster_name_with_prefix = absl::StrCat("cluster:", kClusterName); |
||||
XdsClusterAttribute xds_cluster_attribute(cluster_name_with_prefix); |
||||
service_config_call_data->SetCallAttribute(&xds_cluster_attribute); |
||||
EXPECT_EVENT(Started(&call, ::testing::_)); |
||||
call.Start(call.NewClientMetadata()); |
||||
call.FinishNextFilter(call.NewServerMetadata({{"grpc-status", "0"}})); |
||||
EXPECT_EVENT(Finished(&call, HasMetadataResult(absl::OkStatus()))); |
||||
Step(); |
||||
// Call creds were set with the right audience.
|
||||
auto call_creds = GetCallCreds(call); |
||||
ASSERT_NE(call_creds, nullptr); |
||||
EXPECT_EQ(call_creds->type(), |
||||
GcpServiceAccountIdentityCallCredentials::Type()); |
||||
EXPECT_EQ(call_creds->debug_string(), |
||||
absl::StrCat("GcpServiceAccountIdentityCallCredentials(", kAudience, |
||||
")")); |
||||
} |
||||
|
||||
} // namespace
|
||||
} // namespace grpc_core
|
||||
|
||||
int main(int argc, char** argv) { |
||||
::testing::InitGoogleTest(&argc, argv); |
||||
return RUN_ALL_TESTS(); |
||||
} |
@ -0,0 +1,74 @@ |
||||
//
|
||||
// Copyright 2024 gRPC authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
#include "src/core/util/lru_cache.h" |
||||
|
||||
#include "absl/log/check.h" |
||||
#include "absl/strings/numbers.h" |
||||
#include "absl/strings/str_cat.h" |
||||
#include "gmock/gmock.h" |
||||
#include "gtest/gtest.h" |
||||
|
||||
namespace grpc_core { |
||||
|
||||
TEST(LruCache, Basic) { |
||||
std::vector<int> created_list; |
||||
auto create = [&](const std::string& key) { |
||||
int value; |
||||
CHECK(absl::SimpleAtoi(key, &value)); |
||||
created_list.push_back(value); |
||||
return value; |
||||
}; |
||||
// Create a cache with max size 5.
|
||||
LruCache<std::string, int> cache(5); |
||||
// Insert 5 values.
|
||||
const std::array<int, 5> kOrder = {3, 1, 2, 0, 4}; |
||||
for (int i : kOrder) { |
||||
std::string key = absl::StrCat(i); |
||||
EXPECT_EQ(absl::nullopt, cache.Get(key)); |
||||
EXPECT_EQ(i, cache.GetOrInsert(key, create)); |
||||
EXPECT_EQ(i, cache.Get(key)); |
||||
} |
||||
EXPECT_THAT(created_list, ::testing::ElementsAreArray(kOrder)); |
||||
created_list.clear(); |
||||
// Get those same 5 values. This should not trigger any more insertions.
|
||||
for (int i : kOrder) { |
||||
std::string key = absl::StrCat(i); |
||||
EXPECT_EQ(i, cache.GetOrInsert(key, create)); |
||||
} |
||||
EXPECT_THAT(created_list, ::testing::ElementsAre()); |
||||
// Now insert new elements.
|
||||
// Each insertion should remove the least recently used element.
|
||||
const std::array<int, 5> kOrder2 = {7, 6, 8, 5, 9}; |
||||
for (size_t i = 0; i < kOrder2.size(); ++i) { |
||||
int value2 = kOrder2[i]; |
||||
std::string key2 = absl::StrCat(value2); |
||||
EXPECT_EQ(absl::nullopt, cache.Get(key2)); |
||||
EXPECT_EQ(value2, cache.GetOrInsert(key2, create)); |
||||
EXPECT_EQ(value2, cache.Get(key2)); |
||||
int value1 = kOrder[i]; |
||||
std::string key1 = absl::StrCat(value1); |
||||
EXPECT_EQ(absl::nullopt, cache.Get(key1)); |
||||
} |
||||
EXPECT_THAT(created_list, ::testing::ElementsAreArray(kOrder2)); |
||||
} |
||||
|
||||
} // namespace grpc_core
|
||||
|
||||
int main(int argc, char** argv) { |
||||
::testing::InitGoogleTest(&argc, argv); |
||||
return RUN_ALL_TESTS(); |
||||
} |
@ -0,0 +1,231 @@ |
||||
//
|
||||
// Copyright 2024 gRPC authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
#include <string> |
||||
#include <vector> |
||||
|
||||
#include "gmock/gmock.h" |
||||
#include "gtest/gtest.h" |
||||
|
||||
#include <grpc/support/string_util.h> |
||||
|
||||
#include "src/core/client_channel/backup_poller.h" |
||||
#include "src/core/lib/config/config_vars.h" |
||||
#include "src/core/util/http_client/httpcli.h" |
||||
#include "src/proto/grpc/testing/xds/v3/cluster.grpc.pb.h" |
||||
#include "src/proto/grpc/testing/xds/v3/gcp_authn.grpc.pb.h" |
||||
#include "src/proto/grpc/testing/xds/v3/http_connection_manager.grpc.pb.h" |
||||
#include "src/proto/grpc/testing/xds/v3/router.grpc.pb.h" |
||||
#include "test/core/test_util/scoped_env_var.h" |
||||
#include "test/core/test_util/test_config.h" |
||||
#include "test/cpp/end2end/xds/xds_end2end_test_lib.h" |
||||
|
||||
namespace grpc { |
||||
namespace testing { |
||||
namespace { |
||||
|
||||
using ::envoy::extensions::filters::http::gcp_authn::v3::Audience; |
||||
using ::envoy::extensions::filters::http::gcp_authn::v3::GcpAuthnFilterConfig; |
||||
using ::envoy::extensions::filters::network::http_connection_manager::v3:: |
||||
HttpFilter; |
||||
|
||||
constexpr absl::string_view kFilterInstanceName = "gcp_authn_instance"; |
||||
constexpr absl::string_view kAudience = "audience"; |
||||
|
||||
class XdsGcpAuthnEnd2endTest : public XdsEnd2endTest { |
||||
public: |
||||
void SetUp() override { |
||||
g_audience = ""; |
||||
g_token = nullptr; |
||||
grpc_core::HttpRequest::SetOverride(HttpGetOverride, nullptr, nullptr); |
||||
InitClient(MakeBootstrapBuilder(), /*lb_expected_authority=*/"", |
||||
/*xds_resource_does_not_exist_timeout_ms=*/0, |
||||
/*balancer_authority_override=*/"", /*args=*/nullptr, |
||||
CreateTlsChannelCredentials()); |
||||
} |
||||
|
||||
void TearDown() override { |
||||
XdsEnd2endTest::TearDown(); |
||||
grpc_core::HttpRequest::SetOverride(nullptr, nullptr, nullptr); |
||||
} |
||||
|
||||
static void ValidateHttpRequest(const grpc_http_request* request, |
||||
const grpc_core::URI& uri) { |
||||
EXPECT_THAT( |
||||
uri.query_parameter_map(), |
||||
::testing::ElementsAre(::testing::Pair("audience", g_audience))); |
||||
ASSERT_EQ(request->hdr_count, 1); |
||||
EXPECT_EQ(absl::string_view(request->hdrs[0].key), "Metadata-Flavor"); |
||||
EXPECT_EQ(absl::string_view(request->hdrs[0].value), "Google"); |
||||
} |
||||
|
||||
static int HttpGetOverride(const grpc_http_request* request, |
||||
const grpc_core::URI& uri, |
||||
grpc_core::Timestamp /*deadline*/, |
||||
grpc_closure* on_done, |
||||
grpc_http_response* response) { |
||||
// Intercept only requests for GCP service account identity tokens.
|
||||
if (uri.authority() != "metadata.google.internal." || |
||||
uri.path() != |
||||
"/computeMetadata/v1/instance/service-accounts/default/identity") { |
||||
return 0; |
||||
} |
||||
// Validate request.
|
||||
ValidateHttpRequest(request, uri); |
||||
// Generate response.
|
||||
response->status = 200; |
||||
response->body = gpr_strdup(const_cast<char*>(g_token)); |
||||
response->body_length = strlen(g_token); |
||||
grpc_core::ExecCtx::Run(DEBUG_LOCATION, on_done, absl::OkStatus()); |
||||
return 1; |
||||
} |
||||
|
||||
// Constructs a synthetic JWT token that's just valid enough for the
|
||||
// call creds to extract the expiration date.
|
||||
static std::string MakeToken(grpc_core::Timestamp expiration) { |
||||
gpr_timespec ts = expiration.as_timespec(GPR_CLOCK_REALTIME); |
||||
std::string json = absl::StrCat("{\"exp\":", ts.tv_sec, "}"); |
||||
return absl::StrCat("foo.", absl::WebSafeBase64Escape(json), ".bar"); |
||||
} |
||||
|
||||
Listener BuildListenerWithGcpAuthnFilter(bool optional = false) { |
||||
Listener listener = default_listener_; |
||||
HttpConnectionManager hcm = ClientHcmAccessor().Unpack(listener); |
||||
HttpFilter* filter0 = hcm.mutable_http_filters(0); |
||||
*hcm.add_http_filters() = *filter0; |
||||
filter0->set_name(kFilterInstanceName); |
||||
if (optional) filter0->set_is_optional(true); |
||||
filter0->mutable_typed_config()->PackFrom(GcpAuthnFilterConfig()); |
||||
ClientHcmAccessor().Pack(hcm, &listener); |
||||
return listener; |
||||
} |
||||
|
||||
Cluster BuildClusterWithAudience(absl::string_view audience) { |
||||
Audience audience_proto; |
||||
audience_proto.set_url(audience); |
||||
Cluster cluster = default_cluster_; |
||||
auto& filter_map = |
||||
*cluster.mutable_metadata()->mutable_typed_filter_metadata(); |
||||
auto& entry = filter_map[kFilterInstanceName]; |
||||
entry.PackFrom(audience_proto); |
||||
return cluster; |
||||
} |
||||
|
||||
static absl::string_view g_audience; |
||||
static const char* g_token; |
||||
}; |
||||
|
||||
absl::string_view XdsGcpAuthnEnd2endTest::g_audience; |
||||
const char* XdsGcpAuthnEnd2endTest::g_token; |
||||
|
||||
INSTANTIATE_TEST_SUITE_P(XdsTest, XdsGcpAuthnEnd2endTest, |
||||
::testing::Values(XdsTestType()), &XdsTestType::Name); |
||||
|
||||
TEST_P(XdsGcpAuthnEnd2endTest, Basic) { |
||||
grpc_core::testing::ScopedExperimentalEnvVar env( |
||||
"GRPC_EXPERIMENTAL_XDS_GCP_AUTHENTICATION_FILTER"); |
||||
// Construct auth token.
|
||||
g_audience = kAudience; |
||||
std::string token = MakeToken(grpc_core::Timestamp::InfFuture()); |
||||
g_token = token.c_str(); |
||||
// Set xDS resources.
|
||||
CreateAndStartBackends(1, /*xds_enabled=*/false, |
||||
CreateTlsServerCredentials()); |
||||
SetListenerAndRouteConfiguration(balancer_.get(), |
||||
BuildListenerWithGcpAuthnFilter(), |
||||
default_route_config_); |
||||
balancer_->ads_service()->SetCdsResource(BuildClusterWithAudience(kAudience)); |
||||
EdsResourceArgs args({{"locality0", CreateEndpointsForBackends()}}); |
||||
balancer_->ads_service()->SetEdsResource(BuildEdsResource(args)); |
||||
// Send an RPC and check that it arrives with the right auth token.
|
||||
std::multimap<std::string, std::string> server_initial_metadata; |
||||
Status status = SendRpc(RpcOptions().set_echo_metadata_initially(true), |
||||
/*response=*/nullptr, &server_initial_metadata); |
||||
EXPECT_TRUE(status.ok()) << "code=" << status.error_code() |
||||
<< " message=" << status.error_message(); |
||||
EXPECT_THAT(server_initial_metadata, |
||||
::testing::Contains(::testing::Pair( |
||||
"authorization", absl::StrCat("Bearer ", g_token)))); |
||||
} |
||||
|
||||
TEST_P(XdsGcpAuthnEnd2endTest, NoOpWhenClusterHasNoAudience) { |
||||
grpc_core::testing::ScopedExperimentalEnvVar env( |
||||
"GRPC_EXPERIMENTAL_XDS_GCP_AUTHENTICATION_FILTER"); |
||||
// Set xDS resources.
|
||||
CreateAndStartBackends(1, /*xds_enabled=*/false, |
||||
CreateTlsServerCredentials()); |
||||
SetListenerAndRouteConfiguration(balancer_.get(), |
||||
BuildListenerWithGcpAuthnFilter(), |
||||
default_route_config_); |
||||
EdsResourceArgs args({{"locality0", CreateEndpointsForBackends()}}); |
||||
balancer_->ads_service()->SetEdsResource(BuildEdsResource(args)); |
||||
// Send an RPC and check that it does not have an auth token.
|
||||
std::multimap<std::string, std::string> server_initial_metadata; |
||||
Status status = SendRpc(RpcOptions().set_echo_metadata_initially(true), |
||||
/*response=*/nullptr, &server_initial_metadata); |
||||
EXPECT_TRUE(status.ok()) << "code=" << status.error_code() |
||||
<< " message=" << status.error_message(); |
||||
EXPECT_THAT( |
||||
server_initial_metadata, |
||||
::testing::Not(::testing::Contains(::testing::Key("authorization")))); |
||||
} |
||||
|
||||
TEST_P(XdsGcpAuthnEnd2endTest, FilterIgnoredWhenEnvVarNotSet) { |
||||
// Construct auth token.
|
||||
g_audience = kAudience; |
||||
std::string token = MakeToken(grpc_core::Timestamp::InfFuture()); |
||||
g_token = token.c_str(); |
||||
// Set xDS resources.
|
||||
CreateAndStartBackends(1, /*xds_enabled=*/false, |
||||
CreateTlsServerCredentials()); |
||||
SetListenerAndRouteConfiguration( |
||||
balancer_.get(), BuildListenerWithGcpAuthnFilter(/*optional=*/true), |
||||
default_route_config_); |
||||
balancer_->ads_service()->SetCdsResource(BuildClusterWithAudience(kAudience)); |
||||
EdsResourceArgs args({{"locality0", CreateEndpointsForBackends()}}); |
||||
balancer_->ads_service()->SetEdsResource(BuildEdsResource(args)); |
||||
// Send an RPC and check that it does not have an auth token.
|
||||
std::multimap<std::string, std::string> server_initial_metadata; |
||||
Status status = SendRpc(RpcOptions().set_echo_metadata_initially(true), |
||||
/*response=*/nullptr, &server_initial_metadata); |
||||
EXPECT_TRUE(status.ok()) << "code=" << status.error_code() |
||||
<< " message=" << status.error_message(); |
||||
EXPECT_THAT( |
||||
server_initial_metadata, |
||||
::testing::Not(::testing::Contains(::testing::Key("authorization")))); |
||||
} |
||||
|
||||
} // namespace
|
||||
} // namespace testing
|
||||
} // namespace grpc
|
||||
|
||||
int main(int argc, char** argv) { |
||||
grpc::testing::TestEnvironment env(&argc, argv); |
||||
::testing::InitGoogleTest(&argc, argv); |
||||
// Make the backup poller poll very frequently in order to pick up
|
||||
// updates from all the subchannels's FDs.
|
||||
grpc_core::ConfigVars::Overrides overrides; |
||||
overrides.client_channel_backup_poll_interval_ms = 1; |
||||
grpc_core::ConfigVars::SetOverrides(overrides); |
||||
#if TARGET_OS_IPHONE |
||||
// Workaround Apple CFStream bug
|
||||
grpc_core::SetEnv("grpc_cfstream", "0"); |
||||
#endif |
||||
grpc_init(); |
||||
const auto result = RUN_ALL_TESTS(); |
||||
grpc_shutdown(); |
||||
return result; |
||||
} |
Loading…
Reference in new issue