Skip to content

Commit d92ef4d

Browse files
feat(io): refresh vended storage credentials before they expire
1 parent 7c38bd4 commit d92ef4d

17 files changed

Lines changed: 959 additions & 64 deletions

src/iceberg/arrow/s3/arrow_s3_file_io.cc

Lines changed: 305 additions & 27 deletions
Large diffs are not rendered by default.

src/iceberg/arrow/s3/s3_properties.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ struct S3Properties {
4040
static constexpr std::string_view kSecretAccessKey = "s3.secret-access-key";
4141
/// AWS session token (for temporary credentials)
4242
static constexpr std::string_view kSessionToken = "s3.session-token";
43+
/// Epoch milliseconds at which a vended session token stops being valid
44+
static constexpr std::string_view kSessionTokenExpiresAtMs =
45+
"s3.session-token-expires-at-ms";
4346
/// AWS region, standard Iceberg client property.
4447
static constexpr std::string_view kClientRegion = "client.region";
4548
/// Custom endpoint override (for MinIO, LocalStack, etc.)

src/iceberg/catalog/rest/json_serde.cc

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,25 @@ Result<StorageCredential> StorageCredentialFromJson(const nlohmann::json& json)
152152
return credential;
153153
}
154154

155+
/// \brief Reads the optional `storage-credentials` array shared by the
156+
/// LoadTable and LoadCredentials responses.
157+
Result<std::vector<StorageCredential>> StorageCredentialsFromJson(
158+
const nlohmann::json& json) {
159+
std::vector<StorageCredential> credentials;
160+
auto it = json.find(kStorageCredentials);
161+
if (it == json.end() || it->is_null()) {
162+
return credentials;
163+
}
164+
if (!it->is_array()) {
165+
return JsonParseError("Cannot parse storage credentials from non-array");
166+
}
167+
for (const auto& entry : *it) {
168+
ICEBERG_ASSIGN_OR_RAISE(auto credential, StorageCredentialFromJson(entry));
169+
credentials.push_back(std::move(credential));
170+
}
171+
return credentials;
172+
}
173+
155174
template <typename Value>
156175
Result<std::map<int32_t, Value>> KeyValueMapFromJson(const nlohmann::json& json,
157176
std::string_view key) {
@@ -738,19 +757,24 @@ Result<LoadTableResult> LoadTableResultFromJson(const nlohmann::json& json) {
738757
ICEBERG_ASSIGN_OR_RAISE(result.metadata, TableMetadataFromJson(metadata_json));
739758
ICEBERG_ASSIGN_OR_RAISE(result.config,
740759
GetJsonValueOrDefault<decltype(result.config)>(json, kConfig));
741-
if (auto it = json.find(kStorageCredentials); it != json.end() && !it->is_null()) {
742-
if (!it->is_array()) {
743-
return JsonParseError("Cannot parse storage credentials from non-array");
744-
}
745-
for (const auto& entry : *it) {
746-
ICEBERG_ASSIGN_OR_RAISE(auto cred, StorageCredentialFromJson(entry));
747-
result.storage_credentials.push_back(std::move(cred));
748-
}
749-
}
760+
ICEBERG_ASSIGN_OR_RAISE(result.storage_credentials, StorageCredentialsFromJson(json));
750761
ICEBERG_RETURN_UNEXPECTED(result.Validate());
751762
return result;
752763
}
753764

765+
Result<LoadCredentialsResponse> LoadCredentialsResponseFromJson(
766+
const nlohmann::json& json) {
767+
// Required here, unlike in LoadTable: reading a malformed response as "no
768+
// credentials" would look like a refresh that succeeded and dropped them.
769+
if (auto it = json.find(kStorageCredentials); it == json.end() || it->is_null()) {
770+
return JsonParseError("Missing '{}'", kStorageCredentials);
771+
}
772+
LoadCredentialsResponse response;
773+
ICEBERG_ASSIGN_OR_RAISE(response.storage_credentials, StorageCredentialsFromJson(json));
774+
ICEBERG_RETURN_UNEXPECTED(response.Validate());
775+
return response;
776+
}
777+
754778
nlohmann::json ToJson(const ListNamespacesResponse& response) {
755779
nlohmann::json json;
756780
SetOptionalStringField(json, kNextPageToken, response.next_page_token);

src/iceberg/catalog/rest/json_serde_internal.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ template <>
7171
ICEBERG_REST_EXPORT Result<LoadTableResult> FromJson(const nlohmann::json& json);
7272
ICEBERG_REST_EXPORT Result<nlohmann::json> ToJson(const LoadTableResult& model);
7373

74+
// Response-only model: a client never serializes it, so no ToJson.
75+
ICEBERG_REST_EXPORT Result<LoadCredentialsResponse> LoadCredentialsResponseFromJson(
76+
const nlohmann::json& json);
77+
7478
ICEBERG_REST_EXPORT Result<CreateTableRequest> CreateTableRequestFromJson(
7579
const nlohmann::json& json);
7680
template <>

src/iceberg/catalog/rest/rest_catalog.cc

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
#include "iceberg/catalog/rest/rest_util.h"
4343
#include "iceberg/catalog/rest/types.h"
4444
#include "iceberg/json_serde_internal.h"
45+
#include "iceberg/logging/log_macros.h"
4546
#include "iceberg/metrics/metrics_reporters.h"
4647
#include "iceberg/partition_spec.h"
4748
#include "iceberg/result.h"
@@ -508,12 +509,56 @@ Result<std::shared_ptr<auth::AuthSession>> RestCatalog::TableAuthSession(
508509
std::move(contextual_session));
509510
}
510511

512+
StorageCredentialRefresher RestCatalog::MakeCredentialRefresher(
513+
const TableIdentifier& identifier,
514+
std::shared_ptr<auth::AuthSession> table_session) const {
515+
if (!supported_endpoints_.contains(Endpoint::TableCredentials())) {
516+
// Not an error, but it surfaces much later as credentials expiring.
517+
ICEBERG_LOG_DEBUG(
518+
"Catalog does not advertise {}; vended credentials for '{}' will not be "
519+
"refreshed",
520+
Endpoint::TableCredentials().ToString(), ToString(identifier));
521+
return nullptr;
522+
}
523+
auto path = paths_->Credentials(identifier);
524+
if (!path.has_value()) {
525+
ICEBERG_LOG_WARN(
526+
"Cannot build the credentials path for '{}' ({}); its vended credentials "
527+
"will not be refreshed",
528+
ToString(identifier), path.error().message);
529+
return nullptr;
530+
}
531+
auto client = client_;
532+
auto credentials_path = std::move(path.value());
533+
auto session = std::move(table_session);
534+
// The catalog's destructor closes the session, and a table's FileIO can
535+
// outlive the table keeping the catalog alive. No cycle: the catalog's own
536+
// FileIO never gets a refresher.
537+
auto catalog = shared_from_this();
538+
return [catalog, client, credentials_path,
539+
session]() -> Result<std::vector<StorageCredential>> {
540+
ICEBERG_ASSIGN_OR_RAISE(const auto response,
541+
client->Get(credentials_path, /*params=*/{}, /*headers=*/{},
542+
*TableErrorHandler::Instance(), *session));
543+
ICEBERG_ASSIGN_OR_RAISE(auto json, FromJsonString(response.body()));
544+
ICEBERG_ASSIGN_OR_RAISE(auto result, LoadCredentialsResponseFromJson(json));
545+
return std::move(result.storage_credentials);
546+
};
547+
}
548+
511549
Result<std::shared_ptr<FileIO>> RestCatalog::TableFileIO(
512-
const SessionContext& /*context*/,
550+
const SessionContext& /*context*/, const TableIdentifier& identifier,
513551
const std::unordered_map<std::string, std::string>& table_config,
514-
const std::vector<StorageCredential>& storage_credentials) const {
552+
const std::vector<StorageCredential>& storage_credentials,
553+
std::shared_ptr<auth::AuthSession> table_session) const {
515554
if (!table_config.empty() || !storage_credentials.empty()) {
516-
return MakeTableFileIO(config_.configs(), table_config, storage_credentials);
555+
// Only vended credentials expire, so only they need a refresher.
556+
StorageCredentialRefresher refresher;
557+
if (!storage_credentials.empty()) {
558+
refresher = MakeCredentialRefresher(identifier, std::move(table_session));
559+
}
560+
return MakeTableFileIO(config_.configs(), table_config, storage_credentials,
561+
std::move(refresher));
517562
}
518563

519564
return file_io_;
@@ -772,11 +817,12 @@ Result<std::shared_ptr<Transaction>> RestCatalog::StageCreateTable(
772817
/*stage_create=*/true, *contextual_session));
773818
auto table_config = std::move(result.config);
774819
auto storage_credentials = std::move(result.storage_credentials);
775-
ICEBERG_ASSIGN_OR_RAISE(auto table_io,
776-
TableFileIO(context, table_config, storage_credentials));
820+
// Before the FileIO: refreshing its credentials reuses the table session.
777821
ICEBERG_ASSIGN_OR_RAISE(
778822
auto table_session,
779823
TableAuthSession(identifier, table_config, std::move(contextual_session)));
824+
ICEBERG_ASSIGN_OR_RAISE(auto table_io, TableFileIO(context, identifier, table_config,
825+
storage_credentials, table_session));
780826
ICEBERG_ASSIGN_OR_RAISE(auto reporter, MakeTableReporter(identifier, table_session));
781827
auto table_catalog = std::make_shared<TableScopedCatalog>(
782828
shared_from_this(), context, identifier, table_config, std::move(table_session),
@@ -890,11 +936,12 @@ Result<std::shared_ptr<Table>> RestCatalog::MakeTableFromLoadResult(
890936
std::shared_ptr<auth::AuthSession> contextual_session) {
891937
auto table_config = std::move(result.config);
892938
auto storage_credentials = std::move(result.storage_credentials);
893-
ICEBERG_ASSIGN_OR_RAISE(auto table_io,
894-
TableFileIO(context, table_config, storage_credentials));
939+
// Before the FileIO: refreshing its credentials reuses the table session.
895940
ICEBERG_ASSIGN_OR_RAISE(
896941
auto table_session,
897942
TableAuthSession(identifier, table_config, std::move(contextual_session)));
943+
ICEBERG_ASSIGN_OR_RAISE(auto table_io, TableFileIO(context, identifier, table_config,
944+
storage_credentials, table_session));
898945
ICEBERG_ASSIGN_OR_RAISE(auto reporter, MakeTableReporter(identifier, table_session));
899946
auto table_catalog = std::make_shared<TableScopedCatalog>(
900947
shared_from_this(), context, identifier, table_config, table_session, table_io);

src/iceberg/catalog/rest/rest_catalog.h

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,16 @@ class ICEBERG_REST_EXPORT RestCatalog final
8585
std::shared_ptr<auth::AuthSession> contextual_session);
8686

8787
Result<std::shared_ptr<FileIO>> TableFileIO(
88-
const SessionContext& context,
88+
const SessionContext& context, const TableIdentifier& identifier,
8989
const std::unordered_map<std::string, std::string>& table_config,
90-
const std::vector<StorageCredential>& storage_credentials) const;
90+
const std::vector<StorageCredential>& storage_credentials,
91+
std::shared_ptr<auth::AuthSession> table_session) const;
92+
93+
/// \brief Callback that reloads this table's vended credentials, or nullptr
94+
/// when the catalog does not serve the LoadCredentials endpoint.
95+
StorageCredentialRefresher MakeCredentialRefresher(
96+
const TableIdentifier& identifier,
97+
std::shared_ptr<auth::AuthSession> table_session) const;
9198

9299
Result<std::vector<Namespace>> ListNamespaces(const Namespace& ns,
93100
auth::AuthSession& session) const;

src/iceberg/catalog/rest/rest_file_io.cc

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
#include <string>
2323
#include <unordered_map>
24+
#include <utility>
2425
#include <vector>
2526

2627
#include "iceberg/catalog/rest/types.h"
@@ -61,14 +62,19 @@ Result<std::unique_ptr<FileIO>> MakeCatalogFileIO(const RestCatalogProperties& c
6162
Result<std::unique_ptr<FileIO>> MakeTableFileIO(
6263
const std::unordered_map<std::string, std::string>& catalog_config,
6364
const std::unordered_map<std::string, std::string>& table_config,
64-
const std::vector<StorageCredential>& storage_credentials) {
65+
const std::vector<StorageCredential>& storage_credentials,
66+
StorageCredentialRefresher refresher) {
6567
const auto default_properties = MergeFileIOProperties(catalog_config, table_config);
6668
ICEBERG_ASSIGN_OR_RAISE(
6769
auto io, MakeCatalogFileIO(RestCatalogProperties::FromMap(default_properties)));
6870

6971
if (storage_credentials.empty()) {
7072
return io;
7173
} else if (auto* credentialed = io->AsSupportsStorageCredentials()) {
74+
// First, so the FileIO never briefly holds credentials it cannot replace.
75+
if (refresher) {
76+
credentialed->SetCredentialRefresher(std::move(refresher));
77+
}
7278
ICEBERG_RETURN_UNEXPECTED(credentialed->SetStorageCredentials(storage_credentials));
7379
} else {
7480
return NotSupported("Configured FileIO does not support vended storage credentials");

src/iceberg/catalog/rest/rest_file_io.h

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,13 @@ ICEBERG_REST_EXPORT Result<std::unique_ptr<FileIO>> MakeCatalogFileIO(
4141
const RestCatalogProperties& config);
4242

4343
/// \brief Build the configured table FileIO and apply storage credentials if present.
44+
///
45+
/// \param refresher Optional callback used to replace the vended credentials
46+
/// before they expire; ignored when the FileIO cannot tell when they do.
4447
ICEBERG_REST_EXPORT Result<std::unique_ptr<FileIO>> MakeTableFileIO(
4548
const std::unordered_map<std::string, std::string>& catalog_config,
4649
const std::unordered_map<std::string, std::string>& table_config,
47-
const std::vector<StorageCredential>& storage_credentials);
50+
const std::vector<StorageCredential>& storage_credentials,
51+
StorageCredentialRefresher refresher = nullptr);
4852

4953
} // namespace iceberg::rest

src/iceberg/catalog/rest/types.h

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,23 @@ using CreateTableResponse = LoadTableResult;
209209
/// \brief Alias of LoadTableResult used as the body of LoadTableResponse
210210
using LoadTableResponse = LoadTableResult;
211211

212+
/// \brief Response body of the LoadCredentials API.
213+
///
214+
/// Used to replace the credentials vended alongside a table before they expire.
215+
struct ICEBERG_REST_EXPORT LoadCredentialsResponse {
216+
std::vector<StorageCredential> storage_credentials;
217+
218+
/// \brief Validates the LoadCredentialsResponse.
219+
Status Validate() const {
220+
for (const auto& credential : storage_credentials) {
221+
ICEBERG_RETURN_UNEXPECTED(credential.Validate());
222+
}
223+
return {};
224+
}
225+
226+
bool operator==(const LoadCredentialsResponse& other) const = default;
227+
};
228+
212229
/// \brief Response body for listing namespaces.
213230
struct ICEBERG_REST_EXPORT ListNamespacesResponse {
214231
PageToken next_page_token;

src/iceberg/file_io.h

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,8 +193,16 @@ class ICEBERG_EXPORT SupportsStorageCredentials {
193193
virtual Status SetStorageCredentials(
194194
const std::vector<StorageCredential>& storage_credentials) = 0;
195195

196-
/// \brief Return currently installed storage credentials.
197-
virtual const std::vector<StorageCredential>& credentials() const = 0;
196+
/// \brief Return the storage credentials this FileIO holds.
197+
///
198+
/// By value because a refresh may replace them concurrently. An
199+
/// implementation that delegates may report what was installed on it.
200+
virtual std::vector<StorageCredential> credentials() const = 0;
201+
202+
/// \brief Install a callback that re-fetches credentials before they expire.
203+
///
204+
/// Ignored by implementations that cannot tell when theirs expire.
205+
virtual void SetCredentialRefresher(StorageCredentialRefresher /*refresher*/) {}
198206
};
199207

200208
} // namespace iceberg

0 commit comments

Comments
 (0)