Skip to content

Commit 95fc952

Browse files
committed
Check client certificate/token when option EnforceUserTokenCheckRequirement is on (#7511)
(cherry picked from commit 1a6f195)
1 parent e5672ed commit 95fc952

File tree

6 files changed

+157
-74
lines changed

6 files changed

+157
-74
lines changed

ydb/core/grpc_services/grpc_request_proxy.cpp

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -420,9 +420,22 @@ void TGRpcRequestProxyImpl::HandleUndelivery(TEvents::TEvUndelivered::TPtr& ev)
420420

421421
bool TGRpcRequestProxyImpl::IsAuthStateOK(const IRequestProxyCtx& ctx) {
422422
const auto& state = ctx.GetAuthState();
423-
return state.State == NYdbGrpc::TAuthState::AS_OK ||
424-
state.State == NYdbGrpc::TAuthState::AS_FAIL && state.NeedAuth == false ||
425-
state.NeedAuth == false && !ctx.GetYdbToken();
423+
if (state.State == NYdbGrpc::TAuthState::AS_OK) {
424+
return true;
425+
}
426+
427+
const bool authorizationParamsAreSet = ctx.GetYdbToken() || !ctx.FindClientCertPropertyValues().empty();
428+
if (!state.NeedAuth && !authorizationParamsAreSet) {
429+
return true;
430+
}
431+
432+
if (!state.NeedAuth && state.State == NYdbGrpc::TAuthState::AS_FAIL) {
433+
if (AppData()->EnforceUserTokenCheckRequirement && authorizationParamsAreSet) {
434+
return false;
435+
}
436+
return true;
437+
}
438+
return false;
426439
}
427440

428441
void TGRpcRequestProxyImpl::MaybeStartTracing(IRequestProxyCtx& ctx) {

ydb/core/grpc_services/grpc_request_proxy_simple.cpp

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,22 @@ void TGRpcRequestProxySimple::HandleUndelivery(TEvents::TEvUndelivered::TPtr& ev
172172

173173
bool TGRpcRequestProxySimple::IsAuthStateOK(const IRequestProxyCtx& ctx) {
174174
const auto& state = ctx.GetAuthState();
175-
return state.State == NYdbGrpc::TAuthState::AS_OK ||
176-
state.State == NYdbGrpc::TAuthState::AS_FAIL && state.NeedAuth == false ||
177-
state.NeedAuth == false && !ctx.GetYdbToken();
175+
if (state.State == NYdbGrpc::TAuthState::AS_OK) {
176+
return true;
177+
}
178+
179+
const bool authorizationParamsAreSet = ctx.GetYdbToken() || !ctx.FindClientCertPropertyValues().empty();
180+
if (!state.NeedAuth && !authorizationParamsAreSet) {
181+
return true;
182+
}
183+
184+
if (!state.NeedAuth && state.State == NYdbGrpc::TAuthState::AS_FAIL) {
185+
if (AppData()->EnforceUserTokenCheckRequirement && authorizationParamsAreSet) {
186+
return false;
187+
}
188+
return true;
189+
}
190+
return false;
178191
}
179192

180193
template<typename TEvent>

ydb/library/grpc/server/grpc_request.h

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,11 @@ class TGRpcRequestImpl
7373
, RequestLimiter_(std::move(limiter))
7474
, Writer_(new grpc::ServerAsyncResponseWriter<TUniversalResponseRef<TOut>>(&this->Context))
7575
, StateFunc_(&TThis::SetRequestDone)
76+
, Request_(google::protobuf::Arena::CreateMessage<TIn>(&Arena_))
77+
, AuthState_(Server_->NeedAuth())
7678
{
77-
AuthState_ = Server_->NeedAuth() ? TAuthState(true) : TAuthState(false);
78-
Request_ = google::protobuf::Arena::CreateMessage<TIn>(&Arena_);
7979
Y_ABORT_UNLESS(Request_);
8080
GRPC_LOG_DEBUG(Logger_, "[%p] created request Name# %s", this, Name_);
81-
FinishPromise_ = NThreading::NewPromise<EFinishStatus>();
8281
}
8382

8483
TGRpcRequestImpl(TService* server,
@@ -101,13 +100,12 @@ class TGRpcRequestImpl
101100
, RequestLimiter_(std::move(limiter))
102101
, StreamWriter_(new grpc::ServerAsyncWriter<TUniversalResponse<TOut>>(&this->Context))
103102
, StateFunc_(&TThis::SetRequestDone)
103+
, Request_(google::protobuf::Arena::CreateMessage<TIn>(&Arena_))
104+
, AuthState_(Server_->NeedAuth())
105+
, StreamAdaptor_(CreateStreamAdaptor())
104106
{
105-
AuthState_ = Server_->NeedAuth() ? TAuthState(true) : TAuthState(false);
106-
Request_ = google::protobuf::Arena::CreateMessage<TIn>(&Arena_);
107107
Y_ABORT_UNLESS(Request_);
108108
GRPC_LOG_DEBUG(Logger_, "[%p] created streaming request Name# %s", this, Name_);
109-
FinishPromise_ = NThreading::NewPromise<EFinishStatus>();
110-
StreamAdaptor_ = CreateStreamAdaptor();
111109
}
112110

113111
TAsyncFinishResult GetFinishFuture() override {
@@ -549,7 +547,7 @@ class TGRpcRequestImpl
549547
}
550548

551549
using TStateFunc = bool (TThis::*)(bool);
552-
TService* Server_;
550+
TService* Server_ = nullptr;
553551
TOnRequest Cb_;
554552
TRequestCallback RequestCallback_;
555553
TStreamRequestCallback StreamRequestCallback_;
@@ -561,9 +559,9 @@ class TGRpcRequestImpl
561559
THolder<grpc::ServerAsyncResponseWriter<TUniversalResponseRef<TOut>>> Writer_;
562560
THolder<grpc::ServerAsyncWriterInterface<TUniversalResponse<TOut>>> StreamWriter_;
563561
TStateFunc StateFunc_;
564-
TIn* Request_;
565562

566563
google::protobuf::Arena Arena_;
564+
TIn* Request_ = nullptr;
567565
TOnNextReply NextReplyCb_;
568566
ui32 RequestSize = 0;
569567
ui32 ResponseSize = 0;
@@ -577,7 +575,7 @@ class TGRpcRequestImpl
577575

578576
using TFixedEvent = TQueueFixedEvent<TGRpcRequestImpl>;
579577
TFixedEvent OnFinishTag = { this, &TGRpcRequestImpl::OnFinish };
580-
NThreading::TPromise<EFinishStatus> FinishPromise_;
578+
NThreading::TPromise<EFinishStatus> FinishPromise_ = NThreading::NewPromise<EFinishStatus>();
581579
bool SkipUpdateCountersOnError = false;
582580
IStreamAdaptor::TPtr StreamAdaptor_;
583581
std::atomic<bool> ClientLost_ = false;

ydb/services/ydb/ydb_common_ut.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ class TBasicKikimrWithGrpcAndRootSchema {
150150

151151
NYdbGrpc::TServerOptions grpcOption;
152152
if (TestSettings::AUTH) {
153-
grpcOption.SetUseAuth(true);
153+
grpcOption.SetUseAuth(appConfig.GetDomainsConfig().GetSecurityConfig().GetEnforceUserTokenRequirement()); // In real life UseAuth is initialized with EnforceUserTokenRequirement. To avoid incorrect tests we must do the same.
154154
}
155155
grpcOption.SetPort(grpc);
156156
if (TestSettings::SSL) {

ydb/services/ydb/ydb_register_node_ut.cpp

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ struct TKikimrServerForTestNodeRegistration : TBasicKikimrWithGrpcAndRootSchema<
5454

5555
struct TServerInitialization {
5656
bool EnforceUserToken = false;
57+
bool EnforceCheckUserToken = false; // Takes effect when EnforceUserToken = false
5758
bool EnableDynamicNodeAuth = false;
5859
bool EnableWrongIdentity = false;
5960
bool SetNodeAuthValues = false;
@@ -72,6 +73,9 @@ struct TKikimrServerForTestNodeRegistration : TBasicKikimrWithGrpcAndRootSchema<
7273
if (serverInitialization.EnforceUserToken) {
7374
securityConfig.SetEnforceUserTokenRequirement(true);
7475
}
76+
if (serverInitialization.EnforceCheckUserToken) {
77+
securityConfig.SetEnforceUserTokenCheckRequirement(true);
78+
}
7579
if (serverInitialization.EnableDynamicNodeAuth) {
7680
config.MutableClientCertificateAuthorization()->SetRequestClientCertificate(true);
7781
// config.MutableFeatureFlags()->SetEnableDynamicNodeAuthorization(true);
@@ -884,6 +888,47 @@ Y_UNIT_TEST(ServerWithoutCertVerification_ClientDoesNotProvideClientCerts) {
884888
}
885889
}
886890

891+
Y_UNIT_TEST(ServerWithCertVerification_AuthNotRequired) {
892+
// Scenario when we want to turn on secure node registration, but to check it in safe way
893+
const TCertAndKey& caCert = TKikimrTestWithServerCert::GetCACertAndKey();
894+
TProps props = TProps::AsClientServer();
895+
const TCertAndKey clientServerCert = GenerateSignedCert(caCert, props);
896+
897+
props.Organization = "Enemy Org";
898+
const TCertAndKey clientServerEnemyCert = GenerateSignedCert(caCert, props);
899+
900+
TKikimrServerForTestNodeRegistration server({
901+
.EnforceUserToken = false, // still allow not secure way
902+
.EnforceCheckUserToken = true, // when attempt to register with cert arrives, check it as if EnforceUserToken was switched on
903+
.EnableDynamicNodeAuth = true,
904+
.SetNodeAuthValues = true,
905+
.RegisterNodeAllowedSids = {"DefaultClientAuth@cert"}
906+
});
907+
ui16 grpc = server.GetPort();
908+
TString location = TStringBuilder() << "localhost:" << grpc;
909+
910+
SetLogPriority(server);
911+
912+
TDriverConfig secureConnectionConfig;
913+
secureConnectionConfig.UseSecureConnection(caCert.Certificate.c_str())
914+
.UseClientCertificate(clientServerCert.Certificate.c_str(),clientServerCert.PrivateKey.c_str())
915+
.SetEndpoint(location);
916+
917+
TDriverConfig insecureConnectionConfig;
918+
insecureConnectionConfig.UseSecureConnection(caCert.Certificate.c_str())
919+
.SetEndpoint(location);
920+
921+
TDriverConfig enemyConnectionConfig;
922+
enemyConnectionConfig.UseSecureConnection(caCert.Certificate.c_str())
923+
.UseClientCertificate(clientServerEnemyCert.Certificate.c_str(),clientServerEnemyCert.PrivateKey.c_str())
924+
.SetEndpoint(location);
925+
926+
CheckGood(RegisterNode(secureConnectionConfig));
927+
CheckGood(RegisterNode(insecureConnectionConfig)); // without token and cert // EnforceUserToken = false
928+
CheckAccessDenied(RegisterNode(insecureConnectionConfig.SetAuthToken("invalid token")), "Unknown token");
929+
CheckAccessDeniedRegisterNode(RegisterNode(enemyConnectionConfig), "Client certificate failed verification");
930+
}
931+
887932
}
888933

889934
namespace {

ydb/services/ydb/ydb_ut.cpp

Lines changed: 71 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,38 @@ Y_UNIT_TEST_SUITE(TGRpcClientLowTest) {
189189
UNIT_ASSERT(allDoneOk);
190190
}
191191

192+
std::pair<Ydb::StatusIds::StatusCode, grpc::StatusCode> MakeTestRequest(NGRpcProxy::TGRpcClientConfig& clientConfig, const TString& database, const TString& token) {
193+
NYdbGrpc::TCallMeta meta;
194+
if (token) { // empty token => no token
195+
meta.Aux.push_back({YDB_AUTH_TICKET_HEADER, token});
196+
}
197+
meta.Aux.push_back({YDB_DATABASE_HEADER, database});
198+
199+
NYdbGrpc::TGRpcClientLow clientLow;
200+
auto connection = clientLow.CreateGRpcServiceConnection<Ydb::Table::V1::TableService>(clientConfig);
201+
202+
Ydb::StatusIds::StatusCode status;
203+
grpc::StatusCode gStatus;
204+
205+
do {
206+
auto promise = NThreading::NewPromise<void>();
207+
Ydb::Table::CreateSessionRequest request;
208+
NYdbGrpc::TResponseCallback<Ydb::Table::CreateSessionResponse> responseCb =
209+
[&status, &gStatus, promise](NYdbGrpc::TGrpcStatus&& grpcStatus, Ydb::Table::CreateSessionResponse&& response) mutable {
210+
UNIT_ASSERT(!grpcStatus.InternalError);
211+
gStatus = grpc::StatusCode(grpcStatus.GRpcStatusCode);
212+
auto deferred = response.operation();
213+
status = deferred.status();
214+
promise.SetValue();
215+
};
216+
217+
connection->DoRequest(request, std::move(responseCb), &Ydb::Table::V1::TableService::Stub::AsyncCreateSession, meta);
218+
promise.GetFuture().Wait();
219+
} while (status == Ydb::StatusIds::UNAVAILABLE);
220+
Cerr << "TestRequest(database=\"" << database << "\", token=\"" << token << "\") => {" << Ydb::StatusIds::StatusCode_Name(status) << ", " << int(gStatus) << "}" << Endl;
221+
return std::make_pair(status, gStatus);
222+
}
223+
192224
Y_UNIT_TEST(GrpcRequestProxy) {
193225
NKikimrConfig::TAppConfig appConfig;
194226
appConfig.MutableDomainsConfig()->MutableSecurityConfig()->SetEnforceUserTokenRequirement(true);
@@ -197,79 +229,61 @@ Y_UNIT_TEST_SUITE(TGRpcClientLowTest) {
197229
ui16 grpc = server.GetPort();
198230
TString location = TStringBuilder() << "localhost:" << grpc;
199231
auto clientConfig = NGRpcProxy::TGRpcClientConfig(location);
200-
auto doTest = [&](const TString& database) {
201-
NYdbGrpc::TCallMeta meta;
202-
meta.Aux.push_back({YDB_AUTH_TICKET_HEADER, "root@builtin"});
203-
meta.Aux.push_back({YDB_DATABASE_HEADER, database});
204-
205-
NYdbGrpc::TGRpcClientLow clientLow;
206-
auto connection = clientLow.CreateGRpcServiceConnection<Ydb::Table::V1::TableService>(clientConfig);
207232

208-
Ydb::StatusIds::StatusCode status;
209-
int gStatus;
233+
UNIT_ASSERT_EQUAL(MakeTestRequest(clientConfig, "/Root", "root@builtin"), std::make_pair(Ydb::StatusIds::SUCCESS, grpc::StatusCode::OK));
234+
UNIT_ASSERT_EQUAL(MakeTestRequest(clientConfig, "/blabla", "root@builtin"), std::make_pair(Ydb::StatusIds::STATUS_CODE_UNSPECIFIED, grpc::StatusCode::UNAUTHENTICATED));
235+
UNIT_ASSERT_EQUAL(MakeTestRequest(clientConfig, "blabla", "root@builtin"), std::make_pair(Ydb::StatusIds::STATUS_CODE_UNSPECIFIED, grpc::StatusCode::UNAUTHENTICATED));
236+
}
210237

211-
do {
212-
auto promise = NThreading::NewPromise<void>();
213-
Ydb::Table::CreateSessionRequest request;
214-
NYdbGrpc::TResponseCallback<Ydb::Table::CreateSessionResponse> responseCb =
215-
[&status, &gStatus, promise](NYdbGrpc::TGrpcStatus&& grpcStatus, Ydb::Table::CreateSessionResponse&& response) mutable {
216-
UNIT_ASSERT(!grpcStatus.InternalError);
217-
gStatus = grpcStatus.GRpcStatusCode;
218-
auto deferred = response.operation();
219-
status = deferred.status();
220-
promise.SetValue();
221-
};
238+
Y_UNIT_TEST(GrpcRequestProxyWithoutToken) {
239+
NKikimrConfig::TAppConfig appConfig;
240+
appConfig.MutableDomainsConfig()->MutableSecurityConfig()->SetEnforceUserTokenRequirement(true);
241+
TKikimrWithGrpcAndRootSchemaWithAuth server(appConfig);
222242

223-
connection->DoRequest(request, std::move(responseCb), &Ydb::Table::V1::TableService::Stub::AsyncCreateSession, meta);
224-
promise.GetFuture().Wait();
225-
} while (status == Ydb::StatusIds::UNAVAILABLE);
226-
return std::make_pair(status, gStatus);
227-
};
243+
ui16 grpc = server.GetPort();
244+
TString location = TStringBuilder() << "localhost:" << grpc;
245+
auto clientConfig = NGRpcProxy::TGRpcClientConfig(location);
228246

229-
UNIT_ASSERT_VALUES_EQUAL(doTest("/Root"), std::make_pair(Ydb::StatusIds::SUCCESS, 0));
230-
UNIT_ASSERT_VALUES_EQUAL(doTest("/blabla"), std::make_pair(Ydb::StatusIds::STATUS_CODE_UNSPECIFIED, 16));
231-
UNIT_ASSERT_VALUES_EQUAL(doTest("blabla"), std::make_pair(Ydb::StatusIds::STATUS_CODE_UNSPECIFIED, 16));
247+
UNIT_ASSERT_EQUAL(MakeTestRequest(clientConfig, "/Root", ""), std::make_pair(Ydb::StatusIds::STATUS_CODE_UNSPECIFIED, grpc::StatusCode::UNAUTHENTICATED));
248+
UNIT_ASSERT_EQUAL(MakeTestRequest(clientConfig, "/blabla", ""), std::make_pair(Ydb::StatusIds::STATUS_CODE_UNSPECIFIED, grpc::StatusCode::UNAUTHENTICATED));
249+
UNIT_ASSERT_EQUAL(MakeTestRequest(clientConfig, "blabla", ""), std::make_pair(Ydb::StatusIds::STATUS_CODE_UNSPECIFIED, grpc::StatusCode::UNAUTHENTICATED));
232250
}
233251

234-
Y_UNIT_TEST(GrpcRequestProxyWithoutToken) {
252+
void GrpcRequestProxyCheckTokenWhenItIsSpecified(bool enforceUserTokenCheckRequirement) {
235253
NKikimrConfig::TAppConfig appConfig;
236-
appConfig.MutableDomainsConfig()->MutableSecurityConfig()->SetEnforceUserTokenRequirement(true);
254+
appConfig.MutableDomainsConfig()->MutableSecurityConfig()->SetEnforceUserTokenRequirement(false);
255+
appConfig.MutableDomainsConfig()->MutableSecurityConfig()->SetEnforceUserTokenCheckRequirement(enforceUserTokenCheckRequirement);
237256
TKikimrWithGrpcAndRootSchemaWithAuth server(appConfig);
238257

239258
ui16 grpc = server.GetPort();
240259
TString location = TStringBuilder() << "localhost:" << grpc;
241260
auto clientConfig = NGRpcProxy::TGRpcClientConfig(location);
242-
auto doTest = [&](const TString& database) {
243-
NYdbGrpc::TCallMeta meta;
244-
meta.Aux.push_back({YDB_DATABASE_HEADER, database});
245261

246-
NYdbGrpc::TGRpcClientLow clientLow;
247-
auto connection = clientLow.CreateGRpcServiceConnection<Ydb::Table::V1::TableService>(clientConfig);
262+
UNIT_ASSERT_EQUAL(MakeTestRequest(clientConfig, "/Root", ""), std::make_pair(Ydb::StatusIds::SUCCESS, grpc::StatusCode::OK));
263+
UNIT_ASSERT_EQUAL(MakeTestRequest(clientConfig, "/blabla", ""), std::make_pair(Ydb::StatusIds::SUCCESS, grpc::StatusCode::OK));
264+
UNIT_ASSERT_EQUAL(MakeTestRequest(clientConfig, "blabla", ""), std::make_pair(Ydb::StatusIds::SUCCESS, grpc::StatusCode::OK));
248265

249-
Ydb::StatusIds::StatusCode status;
250-
grpc::StatusCode gStatus;
266+
UNIT_ASSERT_EQUAL(MakeTestRequest(clientConfig, "/Root", "root@builtin"), std::make_pair(Ydb::StatusIds::SUCCESS, grpc::StatusCode::OK));
267+
UNIT_ASSERT_EQUAL(MakeTestRequest(clientConfig, "/blabla", "root@builtin"), std::make_pair(Ydb::StatusIds::STATUS_CODE_UNSPECIFIED, grpc::StatusCode::UNAUTHENTICATED));
268+
UNIT_ASSERT_EQUAL(MakeTestRequest(clientConfig, "blabla", "root@builtin"), std::make_pair(Ydb::StatusIds::STATUS_CODE_UNSPECIFIED, grpc::StatusCode::UNAUTHENTICATED));
251269

252-
do {
253-
auto promise = NThreading::NewPromise<void>();
254-
Ydb::Table::CreateSessionRequest request;
255-
NYdbGrpc::TResponseCallback<Ydb::Table::CreateSessionResponse> responseCb =
256-
[&status, &gStatus, promise](NYdbGrpc::TGrpcStatus&& grpcStatus, Ydb::Table::CreateSessionResponse&& response) mutable {
257-
UNIT_ASSERT(!grpcStatus.InternalError);
258-
gStatus = grpc::StatusCode(grpcStatus.GRpcStatusCode);
259-
auto deferred = response.operation();
260-
status = deferred.status();
261-
promise.SetValue();
262-
};
270+
const auto reqResultWithInvalidToken = MakeTestRequest(clientConfig, "/Root", "invalid token");
271+
if (enforceUserTokenCheckRequirement) {
272+
UNIT_ASSERT_EQUAL(reqResultWithInvalidToken, std::make_pair(Ydb::StatusIds::STATUS_CODE_UNSPECIFIED, grpc::StatusCode::UNAUTHENTICATED));
273+
} else {
274+
UNIT_ASSERT_EQUAL(reqResultWithInvalidToken, std::make_pair(Ydb::StatusIds::SUCCESS, grpc::StatusCode::OK));
275+
}
263276

264-
connection->DoRequest(request, std::move(responseCb), &Ydb::Table::V1::TableService::Stub::AsyncCreateSession, meta);
265-
promise.GetFuture().Wait();
266-
} while (status == Ydb::StatusIds::UNAVAILABLE);
267-
return std::make_pair(status, gStatus);
268-
};
277+
UNIT_ASSERT_EQUAL(MakeTestRequest(clientConfig, "/blabla", "invalid token"), std::make_pair(Ydb::StatusIds::STATUS_CODE_UNSPECIFIED, grpc::StatusCode::UNAUTHENTICATED));
278+
UNIT_ASSERT_EQUAL(MakeTestRequest(clientConfig, "blabla", "invalid token"), std::make_pair(Ydb::StatusIds::STATUS_CODE_UNSPECIFIED, grpc::StatusCode::UNAUTHENTICATED));
279+
}
269280

270-
UNIT_ASSERT_EQUAL(doTest("/Root").second, grpc::StatusCode::UNAUTHENTICATED);
271-
UNIT_ASSERT_EQUAL(doTest("/blabla").second, grpc::StatusCode::UNAUTHENTICATED);
272-
UNIT_ASSERT_EQUAL(doTest("blabla").second, grpc::StatusCode::UNAUTHENTICATED);
281+
Y_UNIT_TEST(GrpcRequestProxyCheckTokenWhenItIsSpecified_Ignore) {
282+
GrpcRequestProxyCheckTokenWhenItIsSpecified(false);
283+
}
284+
285+
Y_UNIT_TEST(GrpcRequestProxyCheckTokenWhenItIsSpecified_Check) {
286+
GrpcRequestProxyCheckTokenWhenItIsSpecified(true);
273287
}
274288

275289
Y_UNIT_TEST(BiStreamPing) {
@@ -5614,7 +5628,7 @@ Y_UNIT_TEST(DisableWritesToDatabase) {
56145628

56155629
TTenants tenants(server);
56165630
tenants.Run(tenantPath, 1);
5617-
5631+
56185632
TString table = Sprintf("%s/table", tenantPath.c_str());
56195633
ExecSQL(server, sender, Sprintf(R"(
56205634
CREATE TABLE `%s` (

0 commit comments

Comments
 (0)