This is an automated email from the ASF dual-hosted git repository.
wgtmac pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-cpp.git
The following commit(s) were added to refs/heads/main by this push:
new 79fdb868 feat(rest): load table credentials from the LoadCredentials
endpoint (#899)
79fdb868 is described below
commit 79fdb86853450397f64a4bc22ad93d1cf0eb44c9
Author: Jiajia Li <[email protected]>
AuthorDate: Thu Oct 8 23:46:13 2026 +0800
feat(rest): load table credentials from the LoadCredentials endpoint (#899)
---
src/iceberg/catalog/rest/json_serde.cc | 42 ++++++++++---
src/iceberg/catalog/rest/json_serde_internal.h | 4 ++
src/iceberg/catalog/rest/rest_catalog.cc | 87 +++++++++++++++++++++++---
src/iceberg/catalog/rest/rest_catalog.h | 12 +++-
src/iceberg/catalog/rest/rest_file_io.cc | 16 ++++-
src/iceberg/catalog/rest/rest_file_io.h | 3 +-
src/iceberg/catalog/rest/types.h | 15 +++++
src/iceberg/file_io.h | 13 ++++
src/iceberg/resolving_file_io.cc | 34 ++++++++--
src/iceberg/resolving_file_io.h | 14 ++++-
src/iceberg/storage_credential.h | 12 ++++
src/iceberg/test/resolving_file_io_test.cc | 65 +++++++++++++++++++
src/iceberg/test/rest_file_io_test.cc | 48 ++++++++++++++
src/iceberg/test/rest_json_serde_test.cc | 26 ++++++++
14 files changed, 362 insertions(+), 29 deletions(-)
diff --git a/src/iceberg/catalog/rest/json_serde.cc
b/src/iceberg/catalog/rest/json_serde.cc
index 3ce753f1..098f2ba0 100644
--- a/src/iceberg/catalog/rest/json_serde.cc
+++ b/src/iceberg/catalog/rest/json_serde.cc
@@ -152,6 +152,25 @@ Result<StorageCredential> StorageCredentialFromJson(const
nlohmann::json& json)
return credential;
}
+/// \brief Reads the optional `storage-credentials` array shared by the
+/// LoadTable and LoadCredentials responses.
+Result<std::vector<StorageCredential>> StorageCredentialsFromJson(
+ const nlohmann::json& json) {
+ std::vector<StorageCredential> credentials;
+ auto it = json.find(kStorageCredentials);
+ if (it == json.end() || it->is_null()) {
+ return credentials;
+ }
+ if (!it->is_array()) {
+ return JsonParseError("Cannot parse storage credentials from non-array");
+ }
+ for (const auto& entry : *it) {
+ ICEBERG_ASSIGN_OR_RAISE(auto credential, StorageCredentialFromJson(entry));
+ credentials.push_back(std::move(credential));
+ }
+ return credentials;
+}
+
template <typename Value>
Result<std::map<int32_t, Value>> KeyValueMapFromJson(const nlohmann::json&
json,
std::string_view key) {
@@ -738,19 +757,24 @@ Result<LoadTableResult> LoadTableResultFromJson(const
nlohmann::json& json) {
ICEBERG_ASSIGN_OR_RAISE(result.metadata,
TableMetadataFromJson(metadata_json));
ICEBERG_ASSIGN_OR_RAISE(result.config,
GetJsonValueOrDefault<decltype(result.config)>(json,
kConfig));
- if (auto it = json.find(kStorageCredentials); it != json.end() &&
!it->is_null()) {
- if (!it->is_array()) {
- return JsonParseError("Cannot parse storage credentials from non-array");
- }
- for (const auto& entry : *it) {
- ICEBERG_ASSIGN_OR_RAISE(auto cred, StorageCredentialFromJson(entry));
- result.storage_credentials.push_back(std::move(cred));
- }
- }
+ ICEBERG_ASSIGN_OR_RAISE(result.storage_credentials,
StorageCredentialsFromJson(json));
ICEBERG_RETURN_UNEXPECTED(result.Validate());
return result;
}
+Result<LoadCredentialsResponse> LoadCredentialsResponseFromJson(
+ const nlohmann::json& json) {
+ // Required here, unlike in LoadTable: reading a malformed response as "no
+ // credentials" would look like a refresh that succeeded and dropped them.
+ if (auto it = json.find(kStorageCredentials); it == json.end() ||
it->is_null()) {
+ return JsonParseError("Missing '{}'", kStorageCredentials);
+ }
+ LoadCredentialsResponse response;
+ ICEBERG_ASSIGN_OR_RAISE(response.storage_credentials,
StorageCredentialsFromJson(json));
+ ICEBERG_RETURN_UNEXPECTED(response.Validate());
+ return response;
+}
+
nlohmann::json ToJson(const ListNamespacesResponse& response) {
nlohmann::json json;
SetOptionalStringField(json, kNextPageToken, response.next_page_token);
diff --git a/src/iceberg/catalog/rest/json_serde_internal.h
b/src/iceberg/catalog/rest/json_serde_internal.h
index 6e148e0d..aedd2cd1 100644
--- a/src/iceberg/catalog/rest/json_serde_internal.h
+++ b/src/iceberg/catalog/rest/json_serde_internal.h
@@ -71,6 +71,10 @@ template <>
ICEBERG_REST_EXPORT Result<LoadTableResult> FromJson(const nlohmann::json&
json);
ICEBERG_REST_EXPORT Result<nlohmann::json> ToJson(const LoadTableResult&
model);
+// Response-only model: a client never serializes it, so no ToJson.
+ICEBERG_REST_EXPORT Result<LoadCredentialsResponse>
LoadCredentialsResponseFromJson(
+ const nlohmann::json& json);
+
ICEBERG_REST_EXPORT Result<CreateTableRequest> CreateTableRequestFromJson(
const nlohmann::json& json);
template <>
diff --git a/src/iceberg/catalog/rest/rest_catalog.cc
b/src/iceberg/catalog/rest/rest_catalog.cc
index 9635aebe..e2aaf5f3 100644
--- a/src/iceberg/catalog/rest/rest_catalog.cc
+++ b/src/iceberg/catalog/rest/rest_catalog.cc
@@ -20,6 +20,8 @@
#include "iceberg/catalog/rest/rest_catalog.h"
#include <memory>
+#include <mutex>
+#include <string>
#include <string_view>
#include <tuple>
#include <unordered_map>
@@ -42,6 +44,7 @@
#include "iceberg/catalog/rest/rest_util.h"
#include "iceberg/catalog/rest/types.h"
#include "iceberg/json_serde_internal.h"
+#include "iceberg/logging/log_macros.h"
#include "iceberg/metrics/metrics_reporters.h"
#include "iceberg/partition_spec.h"
#include "iceberg/result.h"
@@ -60,6 +63,41 @@ namespace iceberg::rest {
namespace {
+class RestStorageCredentialProvider final : public StorageCredentialProvider {
+ public:
+ RestStorageCredentialProvider(std::shared_ptr<HttpClient> client,
+ std::shared_ptr<auth::AuthSession> session,
+ std::string path)
+ : client_(std::move(client)),
+ session_(std::move(session)),
+ path_(std::move(path)) {}
+
+ Result<std::vector<StorageCredential>> Load() override {
+ // The provider can be shared by several FileIOs; keep fetches serialized.
+ std::lock_guard lock(mutex_);
+ ICEBERG_ASSIGN_OR_RAISE(const auto response,
+ client_->Get(path_, /*params=*/{}, /*headers=*/{},
+ *TableErrorHandler::Instance(),
*session_));
+ // Parse errors can contain credential data; return a fixed message.
+ auto json = FromJsonString(response.body());
+ if (!json.has_value()) {
+ return JsonParseError("Malformed LoadCredentials response");
+ }
+ auto result = LoadCredentialsResponseFromJson(*json);
+ if (!result.has_value()) {
+ return std::unexpected<Error>(
+ {.kind = result.error().kind, .message = "Malformed LoadCredentials
response"});
+ }
+ return std::move(result->storage_credentials);
+ }
+
+ private:
+ std::shared_ptr<HttpClient> client_;
+ std::shared_ptr<auth::AuthSession> session_;
+ std::string path_;
+ std::mutex mutex_;
+};
+
/// \brief Get the default set of endpoints for backwards compatibility
according to the
/// iceberg rest spec.
std::unordered_set<Endpoint> GetDefaultEndpoints() {
@@ -508,12 +546,45 @@ Result<std::shared_ptr<auth::AuthSession>>
RestCatalog::TableAuthSession(
std::move(contextual_session));
}
+std::shared_ptr<StorageCredentialProvider>
RestCatalog::MakeStorageCredentialProvider(
+ const TableIdentifier& identifier,
+ std::shared_ptr<auth::AuthSession> table_session) const {
+ if (!supported_endpoints_.contains(Endpoint::TableCredentials())) {
+ // Not an error, but it surfaces much later as credentials expiring.
+ ICEBERG_LOG_DEBUG(
+ "Catalog does not advertise {}; vended credentials for '{}' will not
be "
+ "refreshed",
+ Endpoint::TableCredentials().ToString(), ToString(identifier));
+ return nullptr;
+ }
+ auto path = paths_->Credentials(identifier);
+ if (!path.has_value()) {
+ ICEBERG_LOG_WARN(
+ "Cannot build the credentials path for '{}' ({}); its vended
credentials "
+ "will not be refreshed",
+ ToString(identifier), path.error().message);
+ return nullptr;
+ }
+ auto client = client_;
+ auto credentials_path = std::move(path.value());
+ auto session = std::move(table_session);
+ return std::make_shared<RestStorageCredentialProvider>(
+ std::move(client), std::move(session), std::move(credentials_path));
+}
+
Result<std::shared_ptr<FileIO>> RestCatalog::TableFileIO(
- const SessionContext& /*context*/,
+ const SessionContext& /*context*/, const TableIdentifier& identifier,
const std::unordered_map<std::string, std::string>& table_config,
- const std::vector<StorageCredential>& storage_credentials) const {
+ const std::vector<StorageCredential>& storage_credentials,
+ std::shared_ptr<auth::AuthSession> table_session) const {
if (!table_config.empty() || !storage_credentials.empty()) {
- return MakeTableFileIO(config_.configs(), table_config,
storage_credentials);
+ // Only vended credentials expire, so only they need a provider.
+ std::shared_ptr<StorageCredentialProvider> provider;
+ if (!storage_credentials.empty()) {
+ provider = MakeStorageCredentialProvider(identifier,
std::move(table_session));
+ }
+ return MakeTableFileIO(config_.configs(), table_config,
storage_credentials,
+ std::move(provider));
}
return file_io_;
@@ -772,11 +843,12 @@ Result<std::shared_ptr<Transaction>>
RestCatalog::StageCreateTable(
/*stage_create=*/true, *contextual_session));
auto table_config = std::move(result.config);
auto storage_credentials = std::move(result.storage_credentials);
- ICEBERG_ASSIGN_OR_RAISE(auto table_io,
- TableFileIO(context, table_config,
storage_credentials));
+ // Before the FileIO: refreshing its credentials reuses the table session.
ICEBERG_ASSIGN_OR_RAISE(
auto table_session,
TableAuthSession(identifier, table_config,
std::move(contextual_session)));
+ ICEBERG_ASSIGN_OR_RAISE(auto table_io, TableFileIO(context, identifier,
table_config,
+ storage_credentials,
table_session));
ICEBERG_ASSIGN_OR_RAISE(auto reporter, MakeTableReporter(identifier,
table_session));
auto table_catalog = std::make_shared<TableScopedCatalog>(
shared_from_this(), context, identifier, table_config,
std::move(table_session),
@@ -890,11 +962,12 @@ Result<std::shared_ptr<Table>>
RestCatalog::MakeTableFromLoadResult(
std::shared_ptr<auth::AuthSession> contextual_session) {
auto table_config = std::move(result.config);
auto storage_credentials = std::move(result.storage_credentials);
- ICEBERG_ASSIGN_OR_RAISE(auto table_io,
- TableFileIO(context, table_config,
storage_credentials));
+ // Before the FileIO: refreshing its credentials reuses the table session.
ICEBERG_ASSIGN_OR_RAISE(
auto table_session,
TableAuthSession(identifier, table_config,
std::move(contextual_session)));
+ ICEBERG_ASSIGN_OR_RAISE(auto table_io, TableFileIO(context, identifier,
table_config,
+ storage_credentials,
table_session));
ICEBERG_ASSIGN_OR_RAISE(auto reporter, MakeTableReporter(identifier,
table_session));
auto table_catalog = std::make_shared<TableScopedCatalog>(
shared_from_this(), context, identifier, table_config, table_session,
table_io);
diff --git a/src/iceberg/catalog/rest/rest_catalog.h
b/src/iceberg/catalog/rest/rest_catalog.h
index 65b0b5ea..6f65e6d6 100644
--- a/src/iceberg/catalog/rest/rest_catalog.h
+++ b/src/iceberg/catalog/rest/rest_catalog.h
@@ -85,9 +85,17 @@ class ICEBERG_REST_EXPORT RestCatalog final
std::shared_ptr<auth::AuthSession> contextual_session);
Result<std::shared_ptr<FileIO>> TableFileIO(
- const SessionContext& context,
+ const SessionContext& context, const TableIdentifier& identifier,
const std::unordered_map<std::string, std::string>& table_config,
- const std::vector<StorageCredential>& storage_credentials) const;
+ const std::vector<StorageCredential>& storage_credentials,
+ std::shared_ptr<auth::AuthSession> table_session) const;
+
+ /// \brief Build a provider for this table's vended credentials.
+ ///
+ /// Returns nullptr when the catalog does not serve LoadCredentials.
+ std::shared_ptr<StorageCredentialProvider> MakeStorageCredentialProvider(
+ const TableIdentifier& identifier,
+ std::shared_ptr<auth::AuthSession> table_session) const;
Result<std::vector<Namespace>> ListNamespaces(const Namespace& ns,
auth::AuthSession& session)
const;
diff --git a/src/iceberg/catalog/rest/rest_file_io.cc
b/src/iceberg/catalog/rest/rest_file_io.cc
index cdc1204f..4cd10270 100644
--- a/src/iceberg/catalog/rest/rest_file_io.cc
+++ b/src/iceberg/catalog/rest/rest_file_io.cc
@@ -21,11 +21,13 @@
#include <string>
#include <unordered_map>
+#include <utility>
#include <vector>
#include "iceberg/catalog/rest/types.h"
#include "iceberg/file_io.h"
#include "iceberg/file_io_registry.h"
+#include "iceberg/logging/log_macros.h"
#include "iceberg/resolving_file_io.h"
#include "iceberg/util/macros.h"
@@ -57,7 +59,8 @@ Result<std::unique_ptr<FileIO>> MakeCatalogFileIO(const
RestCatalogProperties& c
Result<std::unique_ptr<FileIO>> MakeTableFileIO(
const std::unordered_map<std::string, std::string>& catalog_config,
const std::unordered_map<std::string, std::string>& table_config,
- const std::vector<StorageCredential>& storage_credentials) {
+ const std::vector<StorageCredential>& storage_credentials,
+ std::shared_ptr<StorageCredentialProvider> provider) {
const auto default_properties = MergeFileIOProperties(catalog_config,
table_config);
ICEBERG_ASSIGN_OR_RAISE(
auto io,
MakeCatalogFileIO(RestCatalogProperties::FromMap(default_properties)));
@@ -65,7 +68,16 @@ Result<std::unique_ptr<FileIO>> MakeTableFileIO(
if (storage_credentials.empty()) {
return io;
} else if (auto* credentialed = io->AsSupportsStorageCredentials()) {
-
ICEBERG_RETURN_UNEXPECTED(credentialed->SetStorageCredentials(storage_credentials));
+ // First, so the FileIO never briefly holds credentials it cannot replace.
+ auto status =
credentialed->InitializeStorageCredentials(storage_credentials,
+
std::move(provider));
+ if (!status && !storage_credentials.empty() &&
+ status.error().kind == ErrorKind::kNotSupported) {
+ ICEBERG_LOG_WARN("Configured FileIO cannot refresh vended storage
credentials: {}",
+ status.error().message);
+ status = credentialed->SetStorageCredentials(storage_credentials);
+ }
+ ICEBERG_RETURN_UNEXPECTED(status);
} else {
return NotSupported("Configured FileIO does not support vended storage
credentials");
}
diff --git a/src/iceberg/catalog/rest/rest_file_io.h
b/src/iceberg/catalog/rest/rest_file_io.h
index e2316c3e..5eb9f97f 100644
--- a/src/iceberg/catalog/rest/rest_file_io.h
+++ b/src/iceberg/catalog/rest/rest_file_io.h
@@ -44,6 +44,7 @@ ICEBERG_REST_EXPORT Result<std::unique_ptr<FileIO>>
MakeCatalogFileIO(
ICEBERG_REST_EXPORT Result<std::unique_ptr<FileIO>> MakeTableFileIO(
const std::unordered_map<std::string, std::string>& catalog_config,
const std::unordered_map<std::string, std::string>& table_config,
- const std::vector<StorageCredential>& storage_credentials);
+ const std::vector<StorageCredential>& storage_credentials,
+ std::shared_ptr<StorageCredentialProvider> provider = nullptr);
} // namespace iceberg::rest
diff --git a/src/iceberg/catalog/rest/types.h b/src/iceberg/catalog/rest/types.h
index 20a59fa5..a47a8e85 100644
--- a/src/iceberg/catalog/rest/types.h
+++ b/src/iceberg/catalog/rest/types.h
@@ -209,6 +209,21 @@ using CreateTableResponse = LoadTableResult;
/// \brief Alias of LoadTableResult used as the body of LoadTableResponse
using LoadTableResponse = LoadTableResult;
+/// \brief Response body of the LoadCredentials API.
+struct ICEBERG_REST_EXPORT LoadCredentialsResponse {
+ std::vector<StorageCredential> storage_credentials;
+
+ /// \brief Validates the LoadCredentialsResponse.
+ Status Validate() const {
+ for (const auto& credential : storage_credentials) {
+ ICEBERG_RETURN_UNEXPECTED(credential.Validate());
+ }
+ return {};
+ }
+
+ bool operator==(const LoadCredentialsResponse& other) const = default;
+};
+
/// \brief Response body for listing namespaces.
struct ICEBERG_REST_EXPORT ListNamespacesResponse {
PageToken next_page_token;
diff --git a/src/iceberg/file_io.h b/src/iceberg/file_io.h
index e22e5cf2..4dd96530 100644
--- a/src/iceberg/file_io.h
+++ b/src/iceberg/file_io.h
@@ -29,6 +29,7 @@
#include <span>
#include <string>
#include <string_view>
+#include <utility>
#include <vector>
#include "iceberg/iceberg_export.h"
@@ -198,6 +199,18 @@ class ICEBERG_EXPORT SupportsStorageCredentials {
/// By value because a concurrent install may replace them. An implementation
/// that delegates may report what was installed on it.
virtual std::vector<StorageCredential> credentials() const = 0;
+
+ /// \brief Install initial credentials and their refresh source before first
use.
+ ///
+ /// The default implementation supports static credentials only.
+ virtual Status InitializeStorageCredentials(
+ const std::vector<StorageCredential>& storage_credentials,
+ std::shared_ptr<StorageCredentialProvider> provider) {
+ if (provider) {
+ return NotSupported("Credential refresh is not supported");
+ }
+ return SetStorageCredentials(storage_credentials);
+ }
};
} // namespace iceberg
diff --git a/src/iceberg/resolving_file_io.cc b/src/iceberg/resolving_file_io.cc
index 91be67ea..ddf19b6f 100644
--- a/src/iceberg/resolving_file_io.cc
+++ b/src/iceberg/resolving_file_io.cc
@@ -24,6 +24,7 @@
#include <utility>
#include "iceberg/file_io_registry.h"
+#include "iceberg/logging/log_macros.h"
#include "iceberg/util/location_util.h"
#include "iceberg/util/macros.h"
#include "iceberg/util/string_util.h"
@@ -44,13 +45,19 @@ Result<std::shared_ptr<FileIO>>
ResolvingFileIO::FileIOForPath(
// without static keys may wait on the EC2 metadata service), which would
// stall every other operation. Forwards all credentials; each implementation
// applies the prefixes it understands.
- auto load = [&](const std::vector<StorageCredential>& credentials)
+ auto load = [&](const std::vector<StorageCredential>& credentials,
+ const std::shared_ptr<StorageCredentialProvider>& provider)
-> Result<std::shared_ptr<FileIO>> {
ICEBERG_ASSIGN_OR_RAISE(std::shared_ptr<FileIO> io,
FileIORegistry::Load(name, properties_));
- if (!credentials.empty()) {
- if (auto* credentialed = io->AsSupportsStorageCredentials()) {
-
ICEBERG_RETURN_UNEXPECTED(credentialed->SetStorageCredentials(credentials));
+ if (auto* credentialed = io->AsSupportsStorageCredentials()) {
+ auto status = credentialed->InitializeStorageCredentials(credentials,
provider);
+ if (!status && provider && status.error().kind ==
ErrorKind::kNotSupported) {
+ ICEBERG_LOG_WARN("FileIO '{}' cannot refresh vended storage
credentials", name);
+ status = credentialed->SetStorageCredentials(credentials);
+ }
+ if (!status) {
+ return std::unexpected(status.error());
}
}
return io;
@@ -59,6 +66,7 @@ Result<std::shared_ptr<FileIO>>
ResolvingFileIO::FileIOForPath(
while (true) {
uint64_t generation = 0;
std::vector<StorageCredential> credentials;
+ std::shared_ptr<StorageCredentialProvider> provider;
{
std::shared_lock lock(mutex_);
if (const auto cached = io_by_name_.find(name); cached !=
io_by_name_.end()) {
@@ -66,13 +74,14 @@ Result<std::shared_ptr<FileIO>>
ResolvingFileIO::FileIOForPath(
}
generation = credential_generation_;
credentials = storage_credentials_;
+ provider = provider_;
}
// Declared before the lock, so a delegate that is not cached is torn down
// only after the lock is released.
- auto loaded = load(credentials);
+ auto loaded = load(credentials, provider);
std::unique_lock lock(mutex_);
if (generation != credential_generation_) {
- continue; // Credentials were replaced mid-load; load again with them.
+ continue; // Replaced mid-load; load again with what is installed now.
}
if (const auto cached = io_by_name_.find(name); cached !=
io_by_name_.end()) {
return cached->second;
@@ -137,4 +146,17 @@ std::vector<StorageCredential>
ResolvingFileIO::credentials() const {
return storage_credentials_;
}
+Status ResolvingFileIO::InitializeStorageCredentials(
+ const std::vector<StorageCredential>& storage_credentials,
+ std::shared_ptr<StorageCredentialProvider> provider) {
+ std::unique_lock lock(mutex_);
+ if (!io_by_name_.empty()) {
+ return InvalidArgument("Storage credentials must be initialized before
first use");
+ }
+ storage_credentials_ = storage_credentials;
+ provider_ = std::move(provider);
+ ++credential_generation_;
+ return {};
+}
+
} // namespace iceberg
diff --git a/src/iceberg/resolving_file_io.h b/src/iceberg/resolving_file_io.h
index 9a5cf047..05e11eb8 100644
--- a/src/iceberg/resolving_file_io.h
+++ b/src/iceberg/resolving_file_io.h
@@ -40,8 +40,8 @@ namespace iceberg {
/// \brief FileIO that resolves and caches implementations by registry name.
///
-/// Vended credentials are forwarded to every resolved implementation that
-/// supports them; each applies what it understands.
+/// Vended credentials and any credential provider are forwarded to every
resolved
+/// implementation that supports them; each applies what it understands.
class ICEBERG_EXPORT ResolvingFileIO final : public FileIO,
public SupportsStorageCredentials
{
public:
@@ -62,8 +62,17 @@ class ICEBERG_EXPORT ResolvingFileIO final : public FileIO,
Status SetStorageCredentials(
const std::vector<StorageCredential>& storage_credentials) override;
+ /// \brief Return the credentials installed on this resolver.
+ ///
+ /// Not necessarily the ones in use: a resolved implementation refreshes its
+ /// own without reporting back.
std::vector<StorageCredential> credentials() const override;
+ /// \brief Install initial credentials before the first delegate is loaded.
+ Status InitializeStorageCredentials(
+ const std::vector<StorageCredential>& storage_credentials,
+ std::shared_ptr<StorageCredentialProvider> provider) override;
+
SupportsStorageCredentials* AsSupportsStorageCredentials() override { return
this; }
private:
@@ -74,6 +83,7 @@ class ICEBERG_EXPORT ResolvingFileIO final : public FileIO,
// Guards lazy resolution and credential state.
mutable std::shared_mutex mutex_;
std::vector<StorageCredential> storage_credentials_;
+ std::shared_ptr<StorageCredentialProvider> provider_;
// Bumped by every credential install, so a delegate loaded from an older set
// never reaches the cache.
uint64_t credential_generation_ = 0;
diff --git a/src/iceberg/storage_credential.h b/src/iceberg/storage_credential.h
index 746bad08..d2109961 100644
--- a/src/iceberg/storage_credential.h
+++ b/src/iceberg/storage_credential.h
@@ -24,6 +24,7 @@
#include <string>
#include <unordered_map>
+#include <vector>
#include "iceberg/iceberg_export.h"
#include "iceberg/result.h"
@@ -48,4 +49,15 @@ struct ICEBERG_EXPORT StorageCredential {
bool operator==(const StorageCredential& other) const = default;
};
+/// \brief Source for loading the current vended credentials.
+///
+/// Implementations must be safe for concurrent calls.
+class ICEBERG_EXPORT StorageCredentialProvider {
+ public:
+ virtual ~StorageCredentialProvider() = default;
+
+ /// \brief Load the current credentials. Returns the whole vended list.
+ virtual Result<std::vector<StorageCredential>> Load() = 0;
+};
+
} // namespace iceberg
diff --git a/src/iceberg/test/resolving_file_io_test.cc
b/src/iceberg/test/resolving_file_io_test.cc
index 2189b64a..a3b19487 100644
--- a/src/iceberg/test/resolving_file_io_test.cc
+++ b/src/iceberg/test/resolving_file_io_test.cc
@@ -56,9 +56,28 @@ class RecordingFileIO : public FileIO {
std::vector<std::vector<std::string>> deleted_batches;
};
+class StaticStorageCredentialProvider : public StorageCredentialProvider {
+ public:
+ explicit StaticStorageCredentialProvider(std::vector<StorageCredential>
credentials)
+ : credentials_(std::move(credentials)) {}
+
+ Result<std::vector<StorageCredential>> Load() override { return
credentials_; }
+
+ private:
+ std::vector<StorageCredential> credentials_;
+};
+
class RecordingCredentialedFileIO : public RecordingFileIO,
public SupportsStorageCredentials {
public:
+ Status InitializeStorageCredentials(
+ const std::vector<StorageCredential>& storage_credentials,
+ std::shared_ptr<StorageCredentialProvider> provider) override {
+ credentials_ = storage_credentials;
+ provider_ = std::move(provider);
+ return {};
+ }
+
Status SetStorageCredentials(
const std::vector<StorageCredential>& storage_credentials) override {
credentials_ = storage_credentials;
@@ -69,8 +88,19 @@ class RecordingCredentialedFileIO : public RecordingFileIO,
SupportsStorageCredentials* AsSupportsStorageCredentials() override { return
this; }
+ Status Refresh() {
+ if (!provider_) {
+ return NotFound("no provider installed");
+ }
+ ICEBERG_ASSIGN_OR_RAISE(auto refreshed, provider_->Load());
+ return SetStorageCredentials(refreshed);
+ }
+
+ bool has_provider() const { return provider_ != nullptr; }
+
private:
std::vector<StorageCredential> credentials_;
+ std::shared_ptr<StorageCredentialProvider> provider_;
};
// File-scope recording state: registry factories are process-global, so they
@@ -318,4 +348,39 @@ TEST(ResolvingFileIOTest,
LoadsWithoutTheLockAndDropsStaleDelegates) {
EXPECT_EQ(last->credentials(), fresh);
}
+TEST(ResolvingFileIOTest, ForwardsCredentialProviderToResolvedImplementations)
{
+ RegisterRecordingFileIOs();
+ ResolvingFileIO io({});
+
+ std::vector<StorageCredential> refreshed = {{.prefix = "s3", .config =
{{"k2", "v2"}}}};
+ auto provider = std::make_shared<StaticStorageCredentialProvider>(refreshed);
+ EXPECT_THAT(io.InitializeStorageCredentials(
+ {{.prefix = "s3", .config = {{"k1", "v1"}}}}, provider),
+ IsOk());
+
+ std::ignore = io.NewInputFile("s3://bucket/db/table/data/file.parquet");
+ ASSERT_NE(last_s3_io, nullptr);
+ ASSERT_TRUE(last_s3_io->has_provider());
+ EXPECT_THAT(last_s3_io->Refresh(), IsOk());
+ EXPECT_EQ(last_s3_io->credentials(), refreshed);
+}
+
+TEST(ResolvingFileIOTest, RejectsCredentialProviderAfterFirstUse) {
+ RegisterRecordingFileIOs();
+ ResolvingFileIO io({});
+
+ EXPECT_THAT(io.SetStorageCredentials({{.prefix = "s3", .config = {{"k1",
"v1"}}}}),
+ IsOk());
+ std::ignore = io.NewInputFile("s3://bucket/db/table/data/file.parquet");
+ ASSERT_NE(last_s3_io, nullptr);
+ EXPECT_FALSE(last_s3_io->has_provider());
+ EXPECT_EQ(s3_factory_calls, 1);
+
+ auto provider =
+
std::make_shared<StaticStorageCredentialProvider>(std::vector<StorageCredential>{});
+ EXPECT_THAT(io.InitializeStorageCredentials({}, provider),
+ IsError(ErrorKind::kInvalidArgument));
+ EXPECT_EQ(s3_factory_calls, 1);
+}
+
} // namespace iceberg
diff --git a/src/iceberg/test/rest_file_io_test.cc
b/src/iceberg/test/rest_file_io_test.cc
index 2fce9dac..31a027ab 100644
--- a/src/iceberg/test/rest_file_io_test.cc
+++ b/src/iceberg/test/rest_file_io_test.cc
@@ -50,11 +50,31 @@ class MockFileIO : public FileIO {
Status DeleteFile(const std::string& /*file_location*/) override { return
{}; }
};
+class StaticStorageCredentialProvider : public StorageCredentialProvider {
+ public:
+ explicit StaticStorageCredentialProvider(std::vector<StorageCredential>
credentials)
+ : credentials_(std::move(credentials)) {}
+
+ Result<std::vector<StorageCredential>> Load() override { return
credentials_; }
+
+ private:
+ std::vector<StorageCredential> credentials_;
+};
+
std::vector<StorageCredential> captured_storage_credentials;
std::unordered_map<std::string, std::string> captured_file_io_properties;
+std::shared_ptr<StorageCredentialProvider> captured_provider;
class MockCredentialedFileIO : public MockFileIO, public
SupportsStorageCredentials {
public:
+ Status InitializeStorageCredentials(
+ const std::vector<StorageCredential>& credentials,
+ std::shared_ptr<StorageCredentialProvider> provider) override {
+ captured_storage_credentials = credentials;
+ captured_provider = std::move(provider);
+ return {};
+ }
+
Status SetStorageCredentials(
const std::vector<StorageCredential>& credentials) override {
captured_storage_credentials = credentials;
@@ -155,6 +175,34 @@ TEST(RestFileIOTest,
TableFileIOMergesConfigAndCredentials) {
EXPECT_EQ(credentialed->credentials(), captured_storage_credentials);
}
+TEST(RestFileIOTest, InstallsCredentialProviderOnlyForVendedCredentials) {
+ const std::string custom_impl = "rest-file-io-test-provider";
+ FileIORegistry::Register(
+ custom_impl,
+ {.create = [](const std::unordered_map<std::string, std::string>&
/*properties*/)
+ -> Result<std::unique_ptr<FileIO>> {
+ return std::make_unique<MockCredentialedFileIO>();
+ }});
+ const std::unordered_map<std::string, std::string> table_config{
+ {"io-impl", custom_impl}};
+
+ captured_provider = nullptr;
+ std::vector<StorageCredential> refreshed = {{.prefix = "s3", .config =
{{"k", "v2"}}}};
+ auto provider = std::make_shared<StaticStorageCredentialProvider>(refreshed);
+ auto result = MakeTableFileIO({}, table_config,
+ {{.prefix = "s3", .config = {{"k", "v1"}}}},
provider);
+ ASSERT_THAT(result, IsOk());
+ ASSERT_TRUE(captured_provider);
+ EXPECT_EQ(captured_provider, provider);
+ EXPECT_THAT(captured_provider->Load(), IsOk());
+ EXPECT_EQ(captured_provider->Load().value(), refreshed);
+
+ captured_provider = nullptr;
+ result = MakeTableFileIO({}, table_config, /*storage_credentials=*/{},
provider);
+ ASSERT_THAT(result, IsOk());
+ EXPECT_FALSE(captured_provider);
+}
+
TEST(RestFileIOTest, TableImplOverridesWarehouseScheme) {
captured_file_io_properties.clear();
FileIORegistry::Register(
diff --git a/src/iceberg/test/rest_json_serde_test.cc
b/src/iceberg/test/rest_json_serde_test.cc
index ec41e4a6..70b760b4 100644
--- a/src/iceberg/test/rest_json_serde_test.cc
+++ b/src/iceberg/test/rest_json_serde_test.cc
@@ -1229,6 +1229,32 @@ INSTANTIATE_TEST_SUITE_P(
return info.param.test_name;
});
+TEST(LoadCredentialsResponseTest, ParsesVendedCredentials) {
+ auto json = nlohmann::json::parse(
+
R"({"storage-credentials":[{"prefix":"s3","config":{"s3.access-key-id":"AKIAtest"}}]})");
+ auto response = LoadCredentialsResponseFromJson(json);
+ ASSERT_THAT(response, IsOk());
+ EXPECT_EQ(response->storage_credentials,
+ (std::vector<StorageCredential>{
+ {.prefix = "s3", .config = {{"s3.access-key-id",
"AKIAtest"}}}}));
+}
+
+TEST(LoadCredentialsResponseTest, RejectsResponseWithoutCredentials) {
+ for (std::string_view body : {"{}", R"({"storage-credentials": null})"}) {
+ auto response =
LoadCredentialsResponseFromJson(nlohmann::json::parse(body));
+ EXPECT_THAT(response, IsError(ErrorKind::kJsonParseError));
+ EXPECT_THAT(response, HasErrorMessage("Missing 'storage-credentials'"));
+ }
+}
+
+TEST(LoadCredentialsResponseTest, RejectsNonArrayCredentials) {
+ auto response = LoadCredentialsResponseFromJson(
+ nlohmann::json::parse(R"({"storage-credentials":"oops"})"));
+ EXPECT_THAT(response, IsError(ErrorKind::kJsonParseError));
+ EXPECT_THAT(response,
+ HasErrorMessage("Cannot parse storage credentials from
non-array"));
+}
+
DECLARE_ROUNDTRIP_TEST(CommitTableRequest)
INSTANTIATE_TEST_SUITE_P(