Skip to content

Commit f070f2a

Browse files
committed
fix: return errors for malformed rows and partial credentials in DynamoDB source
1 parent 4701ab1 commit f070f2a

5 files changed

Lines changed: 93 additions & 24 deletions

File tree

libs/server-sdk-dynamodb-source/src/client_factory.cpp

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,32 @@ namespace launchdarkly::server_side::integrations::detail {
88

99
namespace {
1010

11-
bool HasExplicitCredentials(DynamoDBClientOptions const& options) {
12-
return options.aws_access_key_id.has_value() ||
13-
options.aws_secret_access_key.has_value() ||
14-
options.aws_session_token.has_value();
11+
// Verifies that the credential fields in `options` form a valid combination.
12+
// Returns an empty optional on success, or an error string describing what's
13+
// wrong. The valid combinations are:
14+
//
15+
// - none of the three set (fall through to AWS default credential chain)
16+
// - access_key_id + secret_access_key (long-lived IAM keys)
17+
// - access_key_id + secret_access_key + session_token (STS temporary creds)
18+
//
19+
// All other partial combinations would build a misconfigured AWS client that
20+
// fails opaquely at request time; catching them here surfaces the
21+
// misconfiguration up front.
22+
std::optional<std::string> ValidateCredentials(
23+
DynamoDBClientOptions const& options) {
24+
bool const has_key = options.aws_access_key_id.has_value();
25+
bool const has_secret = options.aws_secret_access_key.has_value();
26+
bool const has_token = options.aws_session_token.has_value();
27+
28+
if (has_key != has_secret) {
29+
return "aws_access_key_id and aws_secret_access_key must both be set "
30+
"or both unset";
31+
}
32+
if (has_token && !has_key) {
33+
return "aws_session_token requires aws_access_key_id and "
34+
"aws_secret_access_key";
35+
}
36+
return std::nullopt;
1537
}
1638

1739
Aws::Client::ClientConfiguration BuildConfig(
@@ -39,14 +61,17 @@ Aws::Client::ClientConfiguration BuildConfig(
3961

4062
} // namespace
4163

42-
std::shared_ptr<Aws::DynamoDB::DynamoDBClient> BuildDynamoDBClient(
43-
DynamoDBClientOptions const& options) {
64+
tl::expected<std::shared_ptr<Aws::DynamoDB::DynamoDBClient>, std::string>
65+
BuildDynamoDBClient(DynamoDBClientOptions const& options) {
66+
if (auto err = ValidateCredentials(options)) {
67+
return tl::make_unexpected(std::move(*err));
68+
}
69+
4470
auto const config = BuildConfig(options);
4571

46-
if (HasExplicitCredentials(options)) {
72+
if (options.aws_access_key_id) {
4773
Aws::Auth::AWSCredentials credentials{
48-
options.aws_access_key_id.value_or(""),
49-
options.aws_secret_access_key.value_or(""),
74+
*options.aws_access_key_id, *options.aws_secret_access_key,
5075
options.aws_session_token.value_or("")};
5176
return std::make_shared<Aws::DynamoDB::DynamoDBClient>(credentials,
5277
config);

libs/server-sdk-dynamodb-source/src/client_factory.hpp

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,21 @@
44

55
#include <aws/dynamodb/DynamoDBClient.h>
66

7+
#include <tl/expected.hpp>
8+
79
#include <memory>
10+
#include <string>
811

912
namespace launchdarkly::server_side::integrations::detail {
1013

1114
// Builds an Aws::DynamoDB::DynamoDBClient configured from the supplied
12-
// DynamoDBClientOptions. Caller is responsible for ensuring AwsSdkGuard::Ensure()
13-
// has been called first.
15+
// DynamoDBClientOptions, or returns an error string if the options are
16+
// internally inconsistent (e.g. only one of aws_access_key_id /
17+
// aws_secret_access_key is set).
1418
//
15-
// The shared_ptr return enables future sharing of a single client across
16-
// multiple DynamoDB-backed stores in the same process (e.g. data source +
17-
// big-segment store); today each consumer constructs its own.
18-
std::shared_ptr<Aws::DynamoDB::DynamoDBClient> BuildDynamoDBClient(
19-
DynamoDBClientOptions const& options);
19+
// Caller is responsible for ensuring AwsSdkGuard::Ensure() has been called
20+
// first.
21+
tl::expected<std::shared_ptr<Aws::DynamoDB::DynamoDBClient>, std::string>
22+
BuildDynamoDBClient(DynamoDBClientOptions const& options);
2023

2124
} // namespace launchdarkly::server_side::integrations::detail

libs/server-sdk-dynamodb-source/src/dynamodb_source.cpp

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,13 @@ DynamoDBDataSource::Create(std::string table_name,
3232
DynamoDBClientOptions options) {
3333
try {
3434
detail::AwsSdkGuard::Ensure();
35-
auto client = detail::BuildDynamoDBClient(options);
36-
return std::unique_ptr<DynamoDBDataSource>(new DynamoDBDataSource(
37-
std::move(client), std::move(table_name), std::move(prefix)));
35+
auto maybe_client = detail::BuildDynamoDBClient(options);
36+
if (!maybe_client) {
37+
return tl::make_unexpected(std::move(maybe_client.error()));
38+
}
39+
return std::unique_ptr<DynamoDBDataSource>(
40+
new DynamoDBDataSource(std::move(*maybe_client),
41+
std::move(table_name), std::move(prefix)));
3842
} catch (std::exception const& e) {
3943
return tl::make_unexpected(e.what());
4044
}
@@ -74,7 +78,8 @@ ISerializedDataReader::GetResult DynamoDBDataSource::Get(
7478

7579
auto const it = item.find(kItemAttribute);
7680
if (it == item.end()) {
77-
return std::nullopt;
81+
return tl::make_unexpected(
82+
Error{"DynamoDB row missing expected 'item' attribute"});
7883
}
7984

8085
return SerializedItemDescriptor::Present(0, it->second.GetS());
@@ -102,10 +107,14 @@ ISerializedDataReader::AllResult DynamoDBDataSource::All(
102107
auto const& result = outcome.GetResult();
103108
for (auto const& row : result.GetItems()) {
104109
auto const key_it = row.find(kSortKey);
105-
auto const item_it = row.find(kItemAttribute);
106-
if (key_it == row.end() || item_it == row.end()) {
110+
if (key_it == row.end()) {
107111
continue;
108112
}
113+
auto const item_it = row.find(kItemAttribute);
114+
if (item_it == row.end()) {
115+
return tl::make_unexpected(
116+
Error{"DynamoDB row missing expected 'item' attribute"});
117+
}
109118
items.emplace(
110119
key_it->second.GetS(),
111120
SerializedItemDescriptor::Present(0, item_it->second.GetS()));

libs/server-sdk-dynamodb-source/tests/dynamodb_source_test.cpp

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,25 @@ TEST_F(DynamoDBTests, AllPaginatesAcrossMultiplePages) {
420420
ASSERT_EQ(result->size(), kFlagCount);
421421
}
422422

423+
TEST_F(DynamoDBTests, GetReturnsErrorWhenRowIsMissingItemAttribute) {
424+
WithPrefixedClient(prefix_, [&](auto const& client) {
425+
client.PutRowWithoutItem("features", "foo");
426+
});
427+
428+
auto const result = source->Get(FlagKind{}, "foo");
429+
ASSERT_FALSE(result);
430+
}
431+
432+
TEST_F(DynamoDBTests, AllReturnsErrorWhenRowIsMissingItemAttribute) {
433+
WithPrefixedClient(prefix_, [&](auto const& client) {
434+
client.PutFlag(Flag{"foo", 1, true});
435+
client.PutRowWithoutItem("features", "bar");
436+
});
437+
438+
auto const result = source->All(FlagKind{});
439+
ASSERT_FALSE(result);
440+
}
441+
423442
TEST_F(DynamoDBTests, IdentityReturnsDynamodb) {
424443
ASSERT_EQ(source->Identity(), "dynamodb");
425444
}

libs/server-sdk-dynamodb-source/tests/prefixed_dynamodb_client.hpp

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
#include <aws/core/utils/Outcome.h>
77
#include <aws/dynamodb/DynamoDBClient.h>
8+
#include <aws/dynamodb/DynamoDBErrors.h>
89
#include <aws/dynamodb/model/AttributeDefinition.h>
910
#include <aws/dynamodb/model/AttributeValue.h>
1011
#include <aws/dynamodb/model/CreateTableRequest.h>
@@ -79,8 +80,13 @@ class PrefixedDynamoDBClient {
7980
request.SetTableName(table_name);
8081
auto outcome = client.DeleteTable(request);
8182
if (!outcome.IsSuccess()) {
82-
// Ignore the not-found case (test may not have created the table).
83-
return;
83+
// Tolerate not-found so setup can call this unconditionally.
84+
if (outcome.GetError().GetErrorType() ==
85+
Aws::DynamoDB::DynamoDBErrors::RESOURCE_NOT_FOUND) {
86+
return;
87+
}
88+
FAIL() << "couldn't delete DynamoDB table " << table_name << ": "
89+
<< outcome.GetError().GetMessage();
8490
}
8591
}
8692

@@ -115,6 +121,13 @@ class PrefixedDynamoDBClient {
115121
PutRaw(Prefixed("segments"), key, ts);
116122
}
117123

124+
// Writes a row with only namespace + key, no `item` attribute. Used to
125+
// simulate corrupted/malformed rows in the table.
126+
void PutRowWithoutItem(std::string const& ns_suffix,
127+
std::string const& key) const {
128+
PutRaw(Prefixed(ns_suffix), key, std::nullopt);
129+
}
130+
118131
private:
119132
std::string Prefixed(std::string const& base) const {
120133
if (prefix_.empty()) {

0 commit comments

Comments
 (0)