This is an automated email from the ASF dual-hosted git repository.
lxy-9602 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-cpp.git
The following commit(s) were added to refs/heads/main by this push:
new 6a2c90f0 feat(rest): support DLF authentication (#244)
6a2c90f0 is described below
commit 6a2c90f01a9029d037f29a82950811e30563859b
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Aug 25 13:14:48 2026 +0800
feat(rest): support DLF authentication (#244)
---
CMakeLists.txt | 6 +-
cmake_modules/arrow.diff | 7 +
cmake_modules/orc.diff | 9 +
docs/source/building.rst | 3 +-
docs/source/user_guide/catalog.rst | 42 +-
include/paimon/catalog_options.h | 31 +-
src/paimon/CMakeLists.txt | 8 +
src/paimon/common/catalog_options.cpp | 9 +
src/paimon/common/utils/http_client.cpp | 5 +
src/paimon/common/utils/http_client.h | 2 +
src/paimon/common/utils/options_utils.h | 13 +
src/paimon/common/utils/options_utils_test.cpp | 20 +
src/paimon/rest/dlf_auth.cpp | 811 +++++++++++++++++++++++++
src/paimon/rest/dlf_auth.h | 208 +++++++
src/paimon/rest/dlf_auth_test.cpp | 468 ++++++++++++++
src/paimon/rest/rest_api.cpp | 31 +-
src/paimon/rest/rest_api.h | 8 +-
src/paimon/rest/rest_auth.cpp | 12 +-
src/paimon/rest/rest_auth.h | 5 +
src/paimon/rest/rest_catalog_test.cpp | 8 +-
src/paimon/rest/rest_http_client.cpp | 13 +-
src/paimon/rest/rest_http_client.h | 12 +-
src/paimon/rest/rest_http_client_test.cpp | 19 +
23 files changed, 1716 insertions(+), 34 deletions(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index e99034dc..084e6bf0 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -68,7 +68,8 @@ option(PAIMON_ENABLE_LUMINA "Whether to enable lumina vector
index" OFF)
option(PAIMON_ENABLE_LUCENE "Whether to enable lucene index" OFF)
option(PAIMON_ENABLE_TANTIVY
"Whether to enable tantivy-fulltext global index (Rust FFI,
experimental)" OFF)
-option(PAIMON_ENABLE_REST "Whether to enable the rest catalog (requires
libcurl)" OFF)
+option(PAIMON_ENABLE_REST
+ "Whether to enable the rest catalog (requires libcurl and OpenSSL)" OFF)
if(PAIMON_ENABLE_ORC)
add_definitions(-DPAIMON_ENABLE_ORC)
endif()
@@ -79,6 +80,9 @@ endif()
if(PAIMON_ENABLE_S3 OR PAIMON_ENABLE_REST)
find_package(CURL REQUIRED)
endif()
+if(PAIMON_ENABLE_REST)
+ find_package(OpenSSL 1.1.0 REQUIRED)
+endif()
if(PAIMON_ENABLE_AVRO)
add_definitions(-DPAIMON_ENABLE_AVRO)
endif()
diff --git a/cmake_modules/arrow.diff b/cmake_modules/arrow.diff
index b8b83517..bb71e9c3 100644
--- a/cmake_modules/arrow.diff
+++ b/cmake_modules/arrow.diff
@@ -15,6 +15,13 @@ diff --git a/cpp/cmake_modules/ThirdpartyToolchain.cmake
b/cpp/cmake_modules/Thi
index 8cb3ec83f5..0765df8fa8 100644
--- a/cpp/cmake_modules/ThirdpartyToolchain.cmake
+++ b/cpp/cmake_modules/ThirdpartyToolchain.cmake
+@@ -814,5 +814,6 @@ if(DEFINED ENV{ARROW_THRIFT_URL})
+ set(THRIFT_SOURCE_URL "$ENV{ARROW_THRIFT_URL}")
+ else()
+ set_urls(THRIFT_SOURCE_URL
++
"https://archive.apache.org/dist/thrift/${ARROW_THRIFT_BUILD_VERSION}/thrift-${ARROW_THRIFT_BUILD_VERSION}.tar.gz"
+
"https://www.apache.org/dyn/closer.cgi?action=download&filename=/thrift/${ARROW_THRIFT_BUILD_VERSION}/thrift-${ARROW_THRIFT_BUILD_VERSION}.tar.gz"
+
"https://downloads.apache.org/thrift/${ARROW_THRIFT_BUILD_VERSION}/thrift-${ARROW_THRIFT_BUILD_VERSION}.tar.gz"
@@ -983,6 +983,11 @@ if(CMAKE_TOOLCHAIN_FILE)
list(APPEND EP_COMMON_CMAKE_ARGS
-DCMAKE_TOOLCHAIN_FILE=${CMAKE_TOOLCHAIN_FILE})
endif()
diff --git a/cmake_modules/orc.diff b/cmake_modules/orc.diff
index e4ca4e29..88742dbd 100644
--- a/cmake_modules/orc.diff
+++ b/cmake_modules/orc.diff
@@ -435,3 +435,12 @@ index 9b2c829c7..434841224 100644
set(ORC_FORMAT_VERSION "1.0.0")
set(LZ4_VERSION "1.10.0")
set(SNAPPY_VERSION "1.2.1")
+@@ -140,7 +142,7 @@ if(DEFINED ENV{ORC_FORMAT_URL})
+ set(ORC_FORMAT_SOURCE_URL "$ENV{ORC_FORMAT_URL}")
+ message(STATUS "Using ORC_FORMAT_URL: ${ORC_FORMAT_SOURCE_URL}")
+ else()
+- set(ORC_FORMAT_SOURCE_URL
"https://www.apache.org/dyn/closer.lua/orc/orc-format-${ORC_FORMAT_VERSION}/orc-format-${ORC_FORMAT_VERSION}.tar.gz?action=download"
)
++ set(ORC_FORMAT_SOURCE_URL
"https://archive.apache.org/dist/orc/orc-format-${ORC_FORMAT_VERSION}/orc-format-${ORC_FORMAT_VERSION}.tar.gz"
)
+ message(STATUS "Using DEFAULT URL: ${ORC_FORMAT_SOURCE_URL}")
+ endif()
+ ExternalProject_Add (orc-format_ep
diff --git a/docs/source/building.rst b/docs/source/building.rst
index 466d461b..32c7e67c 100644
--- a/docs/source/building.rst
+++ b/docs/source/building.rst
@@ -182,7 +182,8 @@ boolean flags to ``cmake``.
Linux ``x86_64``; see :ref:`cpp-building-platforms`.
* ``-DPAIMON_ENABLE_LUCENE=ON``: Support for Lucene full-text search indexes
* ``-DPAIMON_ENABLE_TANTIVY=ON``: Enable the experimental Tantivy full-text
index Rust FFI.
-* ``-DPAIMON_ENABLE_REST=ON``: Support for the REST catalog
(``metastore=rest``), requires the libcurl development package.
+* ``-DPAIMON_ENABLE_REST=ON``: Support for the REST catalog
+ (``metastore=rest``), requires the libcurl and OpenSSL development packages.
Third-party dependency source
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/docs/source/user_guide/catalog.rst
b/docs/source/user_guide/catalog.rst
index c57c50c1..b695044d 100644
--- a/docs/source/user_guide/catalog.rst
+++ b/docs/source/user_guide/catalog.rst
@@ -50,9 +50,25 @@ registered on the REST server. The catalog is configured
through the
* ``metastore``: must be ``rest`` to select the REST catalog.
* ``uri``: server url of the REST catalog server.
-* ``token.provider``: authentication provider of the REST catalog; currently
only
- ``bear`` is supported (the protocol's historical spelling of "bearer").
+* ``token.provider``: authentication provider of the REST catalog. ``bear``
+ (the protocol's historical spelling of "bearer") and ``dlf`` are supported.
* ``token``: token of the ``bear`` token provider.
+* ``dlf.region``: region used by DLF request signing. It is inferred from the
+ endpoint URI when omitted.
+* ``dlf.access-key-id`` and ``dlf.access-key-secret``: static DLF access key.
+* ``dlf.security-token``: optional STS security token used with a static access
+ key.
+* ``dlf.token-path``: path to a JSON file containing refreshable DLF
credentials.
+* ``dlf.token-loader``: refreshable credential loader. ``local_file`` reads
+ ``dlf.token-path`` and ``ecs`` obtains an STS token from an ECS RAM role.
+* ``dlf.token-ecs-metadata-url``: ECS RAM role metadata endpoint. It defaults
to
+ ``http://100.100.100.200/latest/meta-data/Ram/security-credentials/``.
+* ``dlf.token-ecs-role-name``: optional ECS RAM role name. The loader discovers
+ the role from the metadata endpoint when it is omitted.
+* ``dlf.signing-algorithm``: ``default`` selects DLF4-HMAC-SHA256 for DLF VPC
+ endpoints and ``openapi`` selects ROA HMAC-SHA1 for DlfNext OpenAPI
endpoints.
+ When omitted, an endpoint containing ``dlfnext`` selects ``openapi`` and
other
+ endpoints select ``default``.
* ``table-default.<key>``: table option defaults applied when a created table
left ``<key>`` unset.
* ``header.<name>``: sent as the ``<name>`` http header on every request to the
@@ -70,6 +86,25 @@ registered on the REST server. The catalog is configured
through the
PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<paimon::Catalog> catalog,
paimon::Catalog::Create(/*root_path=*/"my_instance",
options));
+For DLF, configure one credential source. Static AK/SK credentials, an optional
+STS token, a refreshable local token file, and ECS RAM role credentials are
+supported. A local or ECS token has the Java-compatible JSON fields
+``AccessKeyId``, ``AccessKeySecret``, ``SecurityToken`` and ``Expiration``. The
+last field uses UTC ``yyyy-MM-dd'T'HH:mm:ss'Z'`` format. Refreshable
credentials
+are reloaded when less than one hour of validity remains.
+
+.. code-block:: cpp
+
+ std::map<std::string, std::string> options = {
+ {"metastore", "rest"},
+ {"uri", "https://dlfnext.cn-hangzhou.aliyuncs.com"},
+ {"token.provider", "dlf"},
+ {"dlf.access-key-id", "<access-key-id>"},
+ {"dlf.access-key-secret", "<access-key-secret>"},
+ // Optional for temporary credentials:
+ {"dlf.security-token", "<security-token>"},
+ };
+
On creation the catalog queries the server's ``/v1/config`` endpoint and merges
its response with the options above: the server's overrides win over the client
options, which in turn win over the server's defaults.
@@ -81,5 +116,4 @@ through the regular ``Catalog`` API, and table snapshots can
be listed through
The C++ REST catalog covers the database, table and snapshot operations of the
``Catalog`` API. The parts of the Java REST catalog that have no C++
counterpart
yet — altering a database or a table, views, functions, partitions, tags,
branch
-management and consumers — are not supported, and neither is the ``dlf`` token
-provider.
+management and consumers — are not supported.
diff --git a/include/paimon/catalog_options.h b/include/paimon/catalog_options.h
index f58a876c..e959483d 100644
--- a/include/paimon/catalog_options.h
+++ b/include/paimon/catalog_options.h
@@ -32,10 +32,37 @@ struct PAIMON_EXPORT CatalogOptions {
/// "token" - Token of the "bear" token provider of the REST catalog.
static const char TOKEN[];
- /// "token.provider" - Authentication provider of the REST catalog. Only
"bear" is
- /// supported ("bear" is the protocol's historical spelling of "bearer",
do not "fix" it).
+ /// "token.provider" - Authentication provider of the REST catalog.
Supported values are
+ /// "bear" (the protocol's historical spelling of "bearer") and "dlf".
static const char TOKEN_PROVIDER[];
+ /// "dlf.region" - Region used by DLF request signing. Inferred from URI
when absent.
+ static const char DLF_REGION[];
+
+ /// "dlf.token-path" - Path of a JSON file containing refreshable DLF
credentials.
+ static const char DLF_TOKEN_PATH[];
+
+ /// "dlf.access-key-id" - DLF access key id.
+ static const char DLF_ACCESS_KEY_ID[];
+
+ /// "dlf.access-key-secret" - DLF access key secret.
+ static const char DLF_ACCESS_KEY_SECRET[];
+
+ /// "dlf.security-token" - Optional STS security token used with a DLF
access key.
+ static const char DLF_SECURITY_TOKEN[];
+
+ /// "dlf.token-loader" - Refreshable DLF token loader ("ecs" or
"local_file").
+ static const char DLF_TOKEN_LOADER[];
+
+ /// "dlf.token-ecs-metadata-url" - ECS RAM role metadata endpoint.
+ static const char DLF_TOKEN_ECS_METADATA_URL[];
+
+ /// "dlf.token-ecs-role-name" - Optional ECS RAM role name.
+ static const char DLF_TOKEN_ECS_ROLE_NAME[];
+
+ /// "dlf.signing-algorithm" - DLF signer ("default" or "openapi").
+ static const char DLF_SIGNING_ALGORITHM[];
+
/// "table-default." - Prefix of the catalog options that provide table
option
/// defaults: "table-default.<key>=<value>" applies "<key>=<value>" to a
created
/// table when the caller left "<key>" unset.
diff --git a/src/paimon/CMakeLists.txt b/src/paimon/CMakeLists.txt
index a9810424..8b3a2953 100644
--- a/src/paimon/CMakeLists.txt
+++ b/src/paimon/CMakeLists.txt
@@ -196,6 +196,10 @@ if(PAIMON_ENABLE_S3 OR PAIMON_ENABLE_REST)
list(APPEND PAIMON_COMMON_SRCS common/utils/http_client.cpp)
set(PAIMON_CURL_LINK_LIBS CURL::libcurl)
endif()
+set(PAIMON_REST_LINK_LIBS)
+if(PAIMON_ENABLE_REST)
+ set(PAIMON_REST_LINK_LIBS OpenSSL::Crypto)
+endif()
if(PAIMON_ENABLE_S3)
list(APPEND PAIMON_COMMON_SRCS common/fs/object_store_file_system.cpp)
endif()
@@ -449,6 +453,7 @@ if(PAIMON_ENABLE_REST)
rest/resource_paths.cpp
rest/rest_api.cpp
rest/rest_auth.cpp
+ rest/dlf_auth.cpp
rest/rest_catalog.cpp
rest/rest_messages.cpp
rest/rest_util.cpp)
@@ -468,6 +473,7 @@ add_paimon_lib(paimon
Threads::Threads
RapidJSON
${PAIMON_CURL_LINK_LIBS}
+ ${PAIMON_REST_LINK_LIBS}
DataSketches
STATIC_LINK_LIBS
arrow
@@ -480,6 +486,7 @@ add_paimon_lib(paimon
RapidJSON
DataSketches
${PAIMON_CURL_LINK_LIBS}
+ ${PAIMON_REST_LINK_LIBS}
SHARED_LINK_FLAGS
${PAIMON_VERSION_SCRIPT_FLAGS})
@@ -965,6 +972,7 @@ if(PAIMON_BUILD_TESTS)
rest/rest_http_client_test.cpp
rest/mock_rest_server.cpp
rest/resource_paths_test.cpp
+ rest/dlf_auth_test.cpp
rest/rest_catalog_test.cpp
rest/rest_messages_test.cpp
rest/rest_util_test.cpp
diff --git a/src/paimon/common/catalog_options.cpp
b/src/paimon/common/catalog_options.cpp
index 6e89e80a..2722f06b 100644
--- a/src/paimon/common/catalog_options.cpp
+++ b/src/paimon/common/catalog_options.cpp
@@ -22,6 +22,15 @@ const char CatalogOptions::METASTORE[] = "metastore";
const char CatalogOptions::URI[] = "uri";
const char CatalogOptions::TOKEN[] = "token";
const char CatalogOptions::TOKEN_PROVIDER[] = "token.provider";
+const char CatalogOptions::DLF_REGION[] = "dlf.region";
+const char CatalogOptions::DLF_TOKEN_PATH[] = "dlf.token-path";
+const char CatalogOptions::DLF_ACCESS_KEY_ID[] = "dlf.access-key-id";
+const char CatalogOptions::DLF_ACCESS_KEY_SECRET[] = "dlf.access-key-secret";
+const char CatalogOptions::DLF_SECURITY_TOKEN[] = "dlf.security-token";
+const char CatalogOptions::DLF_TOKEN_LOADER[] = "dlf.token-loader";
+const char CatalogOptions::DLF_TOKEN_ECS_METADATA_URL[] =
"dlf.token-ecs-metadata-url";
+const char CatalogOptions::DLF_TOKEN_ECS_ROLE_NAME[] =
"dlf.token-ecs-role-name";
+const char CatalogOptions::DLF_SIGNING_ALGORITHM[] = "dlf.signing-algorithm";
const char CatalogOptions::TABLE_DEFAULT_OPTION_PREFIX[] = "table-default.";
} // namespace paimon
diff --git a/src/paimon/common/utils/http_client.cpp
b/src/paimon/common/utils/http_client.cpp
index e61c7faa..1542f58a 100644
--- a/src/paimon/common/utils/http_client.cpp
+++ b/src/paimon/common/utils/http_client.cpp
@@ -164,6 +164,9 @@ CurlHttpClient::~CurlHttpClient() = default;
Result<HttpResponse> CurlHttpClient::Execute(const HttpRequest& request,
const HttpBodyConsumer& consumer)
const {
+ if (request.request_timeout_ms < 0) {
+ return Status::Invalid("HTTP request timeout must not be negative");
+ }
for (int32_t attempt = 0; attempt < kMaxAttempts; ++attempt) {
CURL* handle = impl_->Acquire();
if (handle == nullptr) {
@@ -184,6 +187,8 @@ Result<HttpResponse> CurlHttpClient::Execute(const
HttpRequest& request,
curl_easy_setopt(handle, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(handle, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(handle, CURLOPT_CONNECTTIMEOUT_MS, 30000L);
+ curl_easy_setopt(handle, CURLOPT_TIMEOUT_MS,
+ static_cast<long>(request.request_timeout_ms)); //
NOLINT(runtime/int)
curl_easy_setopt(handle, CURLOPT_WRITEFUNCTION, WriteCallback);
curl_easy_setopt(handle, CURLOPT_WRITEDATA, &context);
curl_easy_setopt(handle, CURLOPT_HEADERFUNCTION, HeaderCallback);
diff --git a/src/paimon/common/utils/http_client.h
b/src/paimon/common/utils/http_client.h
index 0dd3e742..8b04a700 100644
--- a/src/paimon/common/utils/http_client.h
+++ b/src/paimon/common/utils/http_client.h
@@ -46,6 +46,8 @@ struct HttpRequest {
HttpMethod method = HttpMethod::GET;
std::string url;
HttpHeaders headers;
+ /// Overall request timeout in milliseconds; zero keeps libcurl's
no-timeout default.
+ int32_t request_timeout_ms = 0;
};
struct HttpResponse {
diff --git a/src/paimon/common/utils/options_utils.h
b/src/paimon/common/utils/options_utils.h
index 90b30b54..c2014007 100644
--- a/src/paimon/common/utils/options_utils.h
+++ b/src/paimon/common/utils/options_utils.h
@@ -76,6 +76,19 @@ class OptionsUtils {
return value.value();
}
+ template <typename T>
+ static Result<std::optional<T>> GetOptionalValueFromMap(
+ const std::map<std::string, std::string>& key_value_map, const
std::string& key) {
+ Result<T> value = GetValueFromMap<T>(key_value_map, key);
+ if (value.ok()) {
+ return std::optional<T>(value.value());
+ }
+ if (value.status().IsNotExist()) {
+ return std::optional<T>();
+ }
+ return value.status();
+ }
+
/// Fetch options with specific prefix and remove prefix for key.
static std::map<std::string, std::string> FetchOptionsWithPrefix(
const std::string& prefix, const std::map<std::string, std::string>&
options) {
diff --git a/src/paimon/common/utils/options_utils_test.cpp
b/src/paimon/common/utils/options_utils_test.cpp
index 7a09a985..d4641184 100644
--- a/src/paimon/common/utils/options_utils_test.cpp
+++ b/src/paimon/common/utils/options_utils_test.cpp
@@ -63,6 +63,26 @@ TEST(OptionsUtilsTest, TestGetValueFromMap) {
ASSERT_EQ(999, empty);
}
+TEST(OptionsUtilsTest, TestGetOptionalValueFromMap) {
+ const std::map<std::string, std::string> key_value_map = {
+ {"key_int", "10"}, {"key_empty", ""}, {"key_invalid", "ab"}};
+
+ ASSERT_OK_AND_ASSIGN(std::optional<int32_t> optional_value,
+
OptionsUtils::GetOptionalValueFromMap<int32_t>(key_value_map, "key_int"));
+ ASSERT_EQ(std::optional<int32_t>(10), optional_value);
+ ASSERT_OK_AND_ASSIGN(
+ std::optional<int32_t> optional_missing,
+ OptionsUtils::GetOptionalValueFromMap<int32_t>(key_value_map,
"key_nonexist"));
+ ASSERT_EQ(std::nullopt, optional_missing);
+ ASSERT_OK_AND_ASSIGN(
+ std::optional<std::string> optional_empty,
+ OptionsUtils::GetOptionalValueFromMap<std::string>(key_value_map,
"key_empty"));
+ ASSERT_EQ(std::optional<std::string>(""), optional_empty);
+ ASSERT_TRUE(OptionsUtils::GetOptionalValueFromMap<int64_t>(key_value_map,
"key_invalid")
+ .status()
+ .IsInvalid());
+}
+
TEST(OptionsUtilsTest, TestFetchOptionsWithPrefix) {
std::map<std::string, std::string> options = {{"key1", "value1"},
{"test.key2", "value2"}};
auto new_options = OptionsUtils::FetchOptionsWithPrefix("test.", options);
diff --git a/src/paimon/rest/dlf_auth.cpp b/src/paimon/rest/dlf_auth.cpp
new file mode 100644
index 00000000..4c592f31
--- /dev/null
+++ b/src/paimon/rest/dlf_auth.cpp
@@ -0,0 +1,811 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 "paimon/rest/dlf_auth.h"
+
+#include <openssl/evp.h>
+
+#include <array>
+#include <cctype>
+#include <climits>
+#include <ctime>
+#include <fstream>
+#include <iomanip>
+#include <limits>
+#include <regex>
+#include <set>
+#include <sstream>
+#include <string_view>
+#include <thread>
+#include <utility>
+
+#include "fmt/format.h"
+#include "paimon/catalog_options.h"
+#include "paimon/common/utils/options_utils.h"
+#include "paimon/common/utils/string_utils.h"
+#include "paimon/common/utils/url_utils.h"
+#include "paimon/common/utils/uuid.h"
+#include "paimon/rest/rest_http_client.h"
+#include "rapidjson/document.h"
+
+namespace paimon {
+
+namespace {
+
+constexpr int32_t kEcsMetadataRequestTimeoutMillis = 3 * 60 * 1000;
+
+constexpr int64_t kTokenExpirationSafeTimeMillis = 60 * 60 * 1000;
+constexpr size_t kMaxTokenResponseBytes = 1024 * 1024;
+constexpr const char kDefaultEcsMetadataUrl[] =
+ "http://100.100.100.200/latest/meta-data/Ram/security-credentials/";
+
+constexpr const char kAuthorizationHeader[] = "Authorization";
+constexpr const char kContentMd5Header[] = "Content-MD5";
+constexpr const char kContentTypeHeader[] = "Content-Type";
+constexpr const char kDlfDateHeader[] = "x-dlf-date";
+constexpr const char kDlfSecurityTokenHeader[] = "x-dlf-security-token";
+constexpr const char kDlfVersionHeader[] = "x-dlf-version";
+constexpr const char kDlfContentSha256Header[] = "x-dlf-content-sha256";
+constexpr const char kUnsignedPayload[] = "UNSIGNED-PAYLOAD";
+constexpr const char kJsonMediaType[] = "application/json";
+
+constexpr const char kOpenApiDateHeader[] = "Date";
+constexpr const char kOpenApiAcceptHeader[] = "Accept";
+constexpr const char kOpenApiHostHeader[] = "Host";
+constexpr const char kAcsSignatureMethodHeader[] = "x-acs-signature-method";
+constexpr const char kAcsSignatureNonceHeader[] = "x-acs-signature-nonce";
+constexpr const char kAcsSignatureVersionHeader[] = "x-acs-signature-version";
+constexpr const char kAcsVersionHeader[] = "x-acs-version";
+constexpr const char kAcsSecurityTokenHeader[] = "x-acs-security-token";
+
+void TrimWhitespace(std::string* value) {
+ size_t begin = 0;
+ while (begin < value->size() && std::isspace(static_cast<unsigned
char>((*value)[begin]))) {
+ ++begin;
+ }
+ size_t end = value->size();
+ while (end > begin && std::isspace(static_cast<unsigned char>((*value)[end
- 1]))) {
+ --end;
+ }
+ *value = value->substr(begin, end - begin);
+}
+
+Result<std::string> RequiredNonEmptyOption(const std::map<std::string,
std::string>& options,
+ const std::string& key) {
+ Result<std::string> value =
OptionsUtils::GetValueFromMap<std::string>(options, key);
+ if (!value.ok()) {
+ if (!value.status().IsNotExist()) {
+ return value.status();
+ }
+ return Status::Invalid(fmt::format("option '{}' must be configured for
DLF auth", key));
+ }
+ if (value.value().empty()) {
+ return Status::Invalid(fmt::format("option '{}' must be configured for
DLF auth", key));
+ }
+ return value.value();
+}
+
+Result<std::string> RequiredJsonString(const rapidjson::Value& object, const
char* key) {
+ if (!object.HasMember(key) || !object[key].IsString() ||
object[key].GetStringLength() == 0) {
+ return Status::Invalid(fmt::format("DLF token field '{}' must be a
non-empty string", key));
+ }
+ return std::string(object[key].GetString(), object[key].GetStringLength());
+}
+
+Result<std::optional<std::string>> OptionalJsonString(const rapidjson::Value&
object,
+ const char* key) {
+ if (!object.HasMember(key) || object[key].IsNull()) {
+ return std::optional<std::string>();
+ }
+ if (!object[key].IsString()) {
+ return Status::Invalid(fmt::format("DLF token field '{}' must be a
string", key));
+ }
+ return std::optional<std::string>(
+ std::string(object[key].GetString(), object[key].GetStringLength()));
+}
+
+Result<std::tm> ToUtc(std::chrono::system_clock::time_point time) {
+ std::time_t seconds = std::chrono::system_clock::to_time_t(time);
+ std::tm utc{};
+ if (gmtime_r(&seconds, &utc) == nullptr) {
+ return Status::Invalid("failed to convert DLF signing time to UTC");
+ }
+ return utc;
+}
+
+Result<std::string> FormatDlfTime(std::chrono::system_clock::time_point time) {
+ PAIMON_ASSIGN_OR_RAISE(std::tm utc, ToUtc(time));
+ std::array<char, 32> buffer{};
+ if (std::strftime(buffer.data(), buffer.size(), "%Y%m%dT%H%M%SZ", &utc) ==
0) {
+ return Status::Invalid("failed to format DLF signing time");
+ }
+ return std::string(buffer.data());
+}
+
+Result<std::string> FormatRfc1123Time(std::chrono::system_clock::time_point
time) {
+ PAIMON_ASSIGN_OR_RAISE(std::tm utc, ToUtc(time));
+ static constexpr std::array<const char*, 7> kWeekdays = {"Sun", "Mon",
"Tue", "Wed",
+ "Thu", "Fri",
"Sat"};
+ static constexpr std::array<const char*, 12> kMonths = {
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct",
"Nov", "Dec"};
+ if (utc.tm_wday < 0 || utc.tm_wday >=
static_cast<int32_t>(kWeekdays.size()) ||
+ utc.tm_mon < 0 || utc.tm_mon >= static_cast<int32_t>(kMonths.size())) {
+ return Status::Invalid("failed to format DLF OpenAPI signing time");
+ }
+ return fmt::format("{}, {:02d} {} {:04d} {:02d}:{:02d}:{:02d} GMT",
kWeekdays[utc.tm_wday],
+ utc.tm_mday, kMonths[utc.tm_mon], utc.tm_year + 1900,
utc.tm_hour,
+ utc.tm_min, utc.tm_sec);
+}
+
+Result<int64_t> ParseExpiration(const std::string& expiration) {
+ static const std::regex kExpirationPattern(
+ "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$");
+ if (!std::regex_match(expiration, kExpirationPattern)) {
+ return Status::Invalid("invalid DLF token expiration");
+ }
+ std::tm utc{};
+ std::istringstream stream(expiration);
+ stream >> std::get_time(&utc, "%Y-%m-%dT%H:%M:%SZ");
+ if (stream.fail() || stream.peek() != std::char_traits<char>::eof()) {
+ return Status::Invalid("invalid DLF token expiration");
+ }
+ int32_t year = utc.tm_year;
+ int32_t month = utc.tm_mon;
+ int32_t day = utc.tm_mday;
+ int32_t hour = utc.tm_hour;
+ int32_t minute = utc.tm_min;
+ int32_t second = utc.tm_sec;
+ std::time_t timestamp = timegm(&utc);
+ std::tm verified{};
+ if (timestamp == static_cast<std::time_t>(-1) || gmtime_r(×tamp,
&verified) == nullptr) {
+ return Status::Invalid("invalid DLF token expiration");
+ }
+ if (verified.tm_year != year || verified.tm_mon != month ||
verified.tm_mday != day ||
+ verified.tm_hour != hour || verified.tm_min != minute ||
verified.tm_sec != second) {
+ return Status::Invalid("invalid DLF token expiration");
+ }
+ if (timestamp > std::numeric_limits<int64_t>::max() / 1000) {
+ return Status::Invalid("DLF token expiration is out of range");
+ }
+ return static_cast<int64_t>(timestamp) * 1000;
+}
+
+using Bytes = std::vector<uint8_t>;
+using EvpMdContext = std::unique_ptr<EVP_MD_CTX, decltype(&EVP_MD_CTX_free)>;
+using EvpPkey = std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)>;
+
+Result<Bytes> Digest(const EVP_MD* digest, std::string_view data) {
+ EvpMdContext context(EVP_MD_CTX_new(), EVP_MD_CTX_free);
+ if (!context || EVP_DigestInit_ex(context.get(), digest, nullptr) != 1 ||
+ EVP_DigestUpdate(context.get(), data.data(), data.size()) != 1) {
+ return Status::IOError("failed to calculate DLF request digest");
+ }
+ Bytes output(EVP_MAX_MD_SIZE);
+ unsigned int output_size = 0;
+ if (EVP_DigestFinal_ex(context.get(), output.data(), &output_size) != 1) {
+ return Status::IOError("failed to calculate DLF request digest");
+ }
+ output.resize(output_size);
+ return output;
+}
+
+Result<Bytes> Hmac(const EVP_MD* digest, const Bytes& key, std::string_view
data) {
+ if (key.size() > static_cast<size_t>(INT_MAX)) {
+ return Status::Invalid("DLF signing key is too large");
+ }
+ EvpPkey signing_key(
+ EVP_PKEY_new_mac_key(EVP_PKEY_HMAC, nullptr, key.data(),
static_cast<int32_t>(key.size())),
+ EVP_PKEY_free);
+ EvpMdContext context(EVP_MD_CTX_new(), EVP_MD_CTX_free);
+ if (!signing_key || !context ||
+ EVP_DigestSignInit(context.get(), nullptr, digest, nullptr,
signing_key.get()) != 1 ||
+ EVP_DigestSignUpdate(context.get(), data.data(), data.size()) != 1) {
+ return Status::IOError("failed to calculate DLF request signature");
+ }
+ size_t output_size = 0;
+ if (EVP_DigestSignFinal(context.get(), nullptr, &output_size) != 1) {
+ return Status::IOError("failed to calculate DLF request signature");
+ }
+ Bytes output(output_size);
+ if (EVP_DigestSignFinal(context.get(), output.data(), &output_size) != 1) {
+ return Status::IOError("failed to calculate DLF request signature");
+ }
+ output.resize(output_size);
+ return output;
+}
+
+Bytes ToBytes(const std::string& value) {
+ return Bytes(value.begin(), value.end());
+}
+
+std::string HexEncode(const Bytes& value) {
+ static constexpr char kHex[] = "0123456789abcdef";
+ std::string encoded;
+ encoded.reserve(value.size() * 2);
+ for (uint8_t byte : value) {
+ encoded.push_back(kHex[byte >> 4]);
+ encoded.push_back(kHex[byte & 0x0f]);
+ }
+ return encoded;
+}
+
+Result<std::string> Base64Encode(const Bytes& value) {
+ if (value.size() > static_cast<size_t>(INT_MAX)) {
+ return Status::Invalid("DLF digest is too large to encode");
+ }
+ size_t capacity = 4 * ((value.size() + 2) / 3) + 1;
+ std::string encoded(capacity, '\0');
+ int32_t size = EVP_EncodeBlock(reinterpret_cast<unsigned
char*>(encoded.data()), value.data(),
+ static_cast<int32_t>(value.size()));
+ if (size < 0) {
+ return Status::IOError("failed to encode DLF request digest");
+ }
+ encoded.resize(static_cast<size_t>(size));
+ return encoded;
+}
+
+Result<std::string> Md5Base64(const std::string& value) {
+ PAIMON_ASSIGN_OR_RAISE(Bytes digest, Digest(EVP_md5(), value));
+ return Base64Encode(digest);
+}
+
+std::string Trimmed(const std::string& value) {
+ std::string trimmed = value;
+ TrimWhitespace(&trimmed);
+ return trimmed;
+}
+
+std::string DefaultCanonicalRequest(const RestAuthParameter& parameter,
+ const DlfRequestSigner::Headers& headers) {
+ std::string canonical = parameter.method + "\n" + parameter.resource_path
+ "\n";
+ bool first = true;
+ for (const auto& [key, value] : parameter.parameters) {
+ if (!first) {
+ canonical += "&";
+ }
+ canonical += Trimmed(key);
+ if (!value.empty()) {
+ canonical += "=" + Trimmed(value);
+ }
+ first = false;
+ }
+
+ static const std::set<std::string> kSignedHeaders = {
+ "content-md5", "content-type", "x-dlf-content-sha256",
+ "x-dlf-date", "x-dlf-version", "x-dlf-security-token"};
+ std::map<std::string, std::string> sorted_headers;
+ for (const auto& [key, value] : headers) {
+ std::string lower_key = StringUtils::ToLowerCase(key);
+ if (kSignedHeaders.count(lower_key) > 0) {
+ sorted_headers[lower_key] = Trimmed(value);
+ }
+ }
+ for (const auto& [key, value] : sorted_headers) {
+ canonical += "\n" + key + ":" + value;
+ }
+ auto content_iter = headers.find(kDlfContentSha256Header);
+ std::string content_sha =
+ content_iter == headers.end() ? std::string(kUnsignedPayload) :
content_iter->second;
+ return canonical + "\n" + content_sha;
+}
+
+Result<std::string> RequiredHeader(const DlfRequestSigner::Headers& headers,
+ const std::string& name) {
+ auto iter = headers.find(name);
+ if (iter == headers.end() || iter->second.empty()) {
+ return Status::Invalid(fmt::format("DLF signing header '{}' is
missing", name));
+ }
+ return iter->second;
+}
+
+std::string OpenApiCanonicalizedHeaders(const DlfRequestSigner::Headers&
headers) {
+ std::map<std::string, std::string> sorted;
+ for (const auto& [key, value] : headers) {
+ std::string lower_key = StringUtils::ToLowerCase(key);
+ if (StringUtils::StartsWith(lower_key, "x-acs-")) {
+ sorted[lower_key] = Trimmed(value);
+ }
+ }
+ std::string canonical;
+ for (const auto& [key, value] : sorted) {
+ canonical += key + ":" + value + "\n";
+ }
+ return canonical;
+}
+
+std::string OpenApiCanonicalizedResource(const RestAuthParameter& parameter) {
+ std::string resource = UrlUtils::DecodeString(parameter.resource_path);
+ if (parameter.parameters.empty()) {
+ return resource;
+ }
+ resource += "?";
+ bool first = true;
+ for (const auto& [key, value] : parameter.parameters) {
+ if (!first) {
+ resource += "&";
+ }
+ resource += key;
+ std::string decoded = UrlUtils::DecodeString(value);
+ if (!decoded.empty()) {
+ resource += "=" + decoded;
+ }
+ first = false;
+ }
+ return resource;
+}
+
+Result<std::string> GenerateNonce(std::chrono::system_clock::time_point now) {
+ std::string uuid;
+ if (!UUID::Generate(&uuid)) {
+ return Status::IOError("failed to generate DLF OpenAPI signing nonce");
+ }
+ int64_t millis =
+
std::chrono::duration_cast<std::chrono::milliseconds>(now.time_since_epoch()).count();
+ std::ostringstream thread_id;
+ thread_id << std::this_thread::get_id();
+ return fmt::format("{}{}{}", uuid, millis, thread_id.str());
+}
+
+Result<std::unique_ptr<DlfRequestSigner>> CreateSigner(const std::string&
algorithm,
+ const std::string&
region) {
+ if (algorithm == DlfDefaultSigner::kIdentifier) {
+ return std::make_unique<DlfDefaultSigner>(region);
+ }
+ if (algorithm == DlfOpenApiSigner::kIdentifier) {
+ return std::make_unique<DlfOpenApiSigner>();
+ }
+ return Status::Invalid(fmt::format(
+ "unsupported DLF signing algorithm '{}', supported values are
'default' and 'openapi'",
+ algorithm));
+}
+
+} // namespace
+
+DlfToken::DlfToken(const std::string& access_key_id, const std::string&
access_key_secret,
+ const std::optional<std::string>& security_token,
+ const std::optional<int64_t>& expiration_at_millis)
+ : access_key_id_(access_key_id),
+ access_key_secret_(access_key_secret),
+ security_token_(security_token),
+ expiration_at_millis_(expiration_at_millis) {}
+
+Result<DlfToken> DlfToken::FromJson(const std::string& json) {
+ rapidjson::Document document;
+ document.Parse(json.data(), json.size());
+ if (document.HasParseError() || !document.IsObject()) {
+ return Status::Invalid("failed to parse DLF token JSON");
+ }
+ PAIMON_ASSIGN_OR_RAISE(std::string access_key_id,
RequiredJsonString(document, "AccessKeyId"));
+ PAIMON_ASSIGN_OR_RAISE(std::string access_key_secret,
+ RequiredJsonString(document, "AccessKeySecret"));
+ PAIMON_ASSIGN_OR_RAISE(std::optional<std::string> security_token,
+ OptionalJsonString(document, "SecurityToken"));
+ PAIMON_ASSIGN_OR_RAISE(std::optional<std::string> expiration,
+ OptionalJsonString(document, "Expiration"));
+ std::optional<int64_t> expiration_at_millis;
+ if (expiration) {
+ PAIMON_ASSIGN_OR_RAISE(int64_t parsed_expiration,
ParseExpiration(expiration.value()));
+ expiration_at_millis = parsed_expiration;
+ }
+ return DlfToken(access_key_id, access_key_secret, security_token,
expiration_at_millis);
+}
+
+bool DlfToken::ShouldRefresh(std::chrono::system_clock::time_point now) const {
+ if (!expiration_at_millis_) {
+ return false;
+ }
+ int64_t now_millis =
+
std::chrono::duration_cast<std::chrono::milliseconds>(now.time_since_epoch()).count();
+ return expiration_at_millis_.value() - now_millis <
kTokenExpirationSafeTimeMillis;
+}
+
+DlfLocalFileTokenLoader::DlfLocalFileTokenLoader(const std::string&
token_file_path,
+ int32_t max_attempts,
+ std::chrono::milliseconds
retry_delay)
+ : token_file_path_(token_file_path), max_attempts_(max_attempts),
retry_delay_(retry_delay) {}
+
+Result<DlfToken> DlfLocalFileTokenLoader::LoadToken() {
+ if (token_file_path_.empty()) {
+ return Status::Invalid("DLF token file path is empty");
+ }
+ if (max_attempts_ <= 0 || retry_delay_.count() < 0) {
+ return Status::Invalid("invalid DLF token file retry configuration");
+ }
+ Status last_status = Status::Invalid("failed to load DLF token file");
+ for (int32_t attempt = 1; attempt <= max_attempts_; ++attempt) {
+ std::ifstream file(token_file_path_, std::ios::binary);
+ if (!file.is_open()) {
+ last_status = Status::IOError(
+ fmt::format("failed to read DLF token file '{}'",
token_file_path_));
+ } else {
+ std::string contents(kMaxTokenResponseBytes + 1, '\0');
+ file.read(contents.data(),
static_cast<std::streamsize>(contents.size()));
+ std::streamsize size = file.gcount();
+ if (file.bad()) {
+ last_status = Status::IOError(
+ fmt::format("failed to read DLF token file '{}'",
token_file_path_));
+ } else if (size >
static_cast<std::streamsize>(kMaxTokenResponseBytes)) {
+ last_status = Status::Invalid("DLF token file is too large");
+ } else {
+ contents.resize(static_cast<size_t>(size));
+ Result<DlfToken> token = DlfToken::FromJson(contents);
+ if (token.ok()) {
+ return token;
+ }
+ last_status = Status::Invalid("failed to parse DLF token
file");
+ }
+ }
+ if (attempt < max_attempts_) {
+ std::this_thread::sleep_for(retry_delay_ * attempt);
+ }
+ }
+ return last_status;
+}
+
+std::string DlfLocalFileTokenLoader::Description() const {
+ return token_file_path_;
+}
+
+DlfEcsTokenLoader::DlfEcsTokenLoader(const std::string& metadata_url,
+ const std::optional<std::string>&
role_name,
+ std::unique_ptr<HttpClient> http_client)
+ : metadata_url_(metadata_url), role_name_(role_name),
http_client_(std::move(http_client)) {}
+
+std::unique_ptr<DlfEcsTokenLoader> DlfEcsTokenLoader::Create(
+ const std::string& metadata_url, const std::optional<std::string>&
role_name) {
+ return std::make_unique<DlfEcsTokenLoader>(metadata_url, role_name,
+
std::make_unique<CurlHttpClient>());
+}
+
+Result<std::string> DlfEcsTokenLoader::Get(const std::string& url) const {
+ if (!http_client_) {
+ return Status::Invalid("DLF ECS metadata HTTP client is not
configured");
+ }
+ HttpRequest request;
+ request.url = url;
+ request.request_timeout_ms = kEcsMetadataRequestTimeoutMillis;
+ std::string body;
+ Result<HttpResponse> response =
+ http_client_->Execute(request, [&body](const char* data, int64_t size)
{
+ if (size < 0 || body.size() + static_cast<size_t>(size) >
kMaxTokenResponseBytes) {
+ return Status::Invalid("DLF ECS metadata response is too
large");
+ }
+ body.append(data, static_cast<size_t>(size));
+ return Status::OK();
+ });
+ if (!response.ok()) {
+ return Status::IOError("failed to request DLF credentials from ECS
metadata service: ",
+ response.status().message());
+ }
+ HttpResponse http_response = std::move(response).value();
+ if (http_response.status_code < 200 || http_response.status_code >= 300) {
+ return Status::IOError(fmt::format("DLF ECS metadata service returned
HTTP status {}",
+ http_response.status_code));
+ }
+ if (StringUtils::IsNullOrWhitespaceOnly(body)) {
+ return Status::Invalid("DLF ECS metadata service returned an empty
response");
+ }
+ return body;
+}
+
+Result<DlfToken> DlfEcsTokenLoader::LoadToken() {
+ if (metadata_url_.empty()) {
+ return Status::Invalid("DLF ECS metadata URL is empty");
+ }
+ if (!role_name_) {
+ PAIMON_ASSIGN_OR_RAISE(std::string role, Get(metadata_url_));
+ TrimWhitespace(&role);
+ if (role.empty()) {
+ return Status::Invalid("DLF ECS metadata service returned an empty
role name");
+ }
+ role_name_ = role;
+ }
+ PAIMON_ASSIGN_OR_RAISE(std::string token_json, Get(metadata_url_ +
role_name_.value()));
+ Result<DlfToken> token = DlfToken::FromJson(token_json);
+ if (!token.ok()) {
+ return Status::Invalid("failed to parse DLF ECS token response");
+ }
+ return token;
+}
+
+std::string DlfEcsTokenLoader::Description() const {
+ return metadata_url_;
+}
+
+DlfDefaultSigner::DlfDefaultSigner(const std::string& region) :
region_(region) {}
+
+Result<DlfRequestSigner::Headers> DlfDefaultSigner::SignHeaders(
+ const std::string& body, std::chrono::system_clock::time_point now,
+ const std::optional<std::string>& security_token, const std::string& host)
const {
+ PAIMON_ASSIGN_OR_RAISE(std::string date_time, FormatDlfTime(now));
+ Headers headers = {{kDlfDateHeader, date_time},
+ {kDlfContentSha256Header, kUnsignedPayload},
+ {kDlfVersionHeader, "v1"}};
+ if (!body.empty()) {
+ PAIMON_ASSIGN_OR_RAISE(std::string content_md5, Md5Base64(body));
+ headers[kContentTypeHeader] = kJsonMediaType;
+ headers[kContentMd5Header] = content_md5;
+ }
+ if (security_token) {
+ headers[kDlfSecurityTokenHeader] = security_token.value();
+ }
+ return headers;
+}
+
+Result<std::string> DlfDefaultSigner::Authorization(const RestAuthParameter&
parameter,
+ const DlfToken& token,
const std::string& host,
+ const Headers&
sign_headers) const {
+ PAIMON_ASSIGN_OR_RAISE(std::string date_time, RequiredHeader(sign_headers,
kDlfDateHeader));
+ if (date_time.size() < 8) {
+ return Status::Invalid("DLF signing date is invalid");
+ }
+ std::string date = date_time.substr(0, 8);
+ std::string scope = fmt::format("{}/{}/DlfNext/aliyun_v4_request", date,
region_);
+ std::string canonical_request = DefaultCanonicalRequest(parameter,
sign_headers);
+ PAIMON_ASSIGN_OR_RAISE(Bytes canonical_hash, Digest(EVP_sha256(),
canonical_request));
+ std::string string_to_sign =
+ fmt::format("DLF4-HMAC-SHA256\n{}\n{}\n{}", date_time, scope,
HexEncode(canonical_hash));
+
+ PAIMON_ASSIGN_OR_RAISE(
+ Bytes date_key,
+ Hmac(EVP_sha256(), ToBytes("aliyun_v4" + token.GetAccessKeySecret()),
date));
+ PAIMON_ASSIGN_OR_RAISE(Bytes region_key, Hmac(EVP_sha256(), date_key,
region_));
+ PAIMON_ASSIGN_OR_RAISE(Bytes service_key, Hmac(EVP_sha256(), region_key,
"DlfNext"));
+ PAIMON_ASSIGN_OR_RAISE(Bytes signing_key, Hmac(EVP_sha256(), service_key,
"aliyun_v4_request"));
+ PAIMON_ASSIGN_OR_RAISE(Bytes signature, Hmac(EVP_sha256(), signing_key,
string_to_sign));
+ return fmt::format("DLF4-HMAC-SHA256 Credential={}/{},Signature={}",
token.GetAccessKeyId(),
+ scope, HexEncode(signature));
+}
+
+Result<DlfRequestSigner::Headers> DlfOpenApiSigner::SignHeaders(
+ const std::string& body, std::chrono::system_clock::time_point now,
+ const std::optional<std::string>& security_token, const std::string& host)
const {
+ if (host.empty()) {
+ return Status::Invalid("DLF OpenAPI signing host is empty");
+ }
+ PAIMON_ASSIGN_OR_RAISE(std::string date, FormatRfc1123Time(now));
+ PAIMON_ASSIGN_OR_RAISE(std::string nonce, GenerateNonce(now));
+ Headers headers = {{kOpenApiDateHeader, date},
{kOpenApiAcceptHeader, kJsonMediaType},
+ {kOpenApiHostHeader, host},
{kAcsSignatureMethodHeader, "HMAC-SHA1"},
+ {kAcsSignatureNonceHeader, nonce},
{kAcsSignatureVersionHeader, "1.0"},
+ {kAcsVersionHeader, "2026-01-18"}};
+ if (!body.empty()) {
+ PAIMON_ASSIGN_OR_RAISE(std::string content_md5, Md5Base64(body));
+ headers[kContentMd5Header] = content_md5;
+ headers[kContentTypeHeader] = kJsonMediaType;
+ }
+ if (security_token) {
+ headers[kAcsSecurityTokenHeader] = security_token.value();
+ }
+ return headers;
+}
+
+Result<std::string> DlfOpenApiSigner::Authorization(const RestAuthParameter&
parameter,
+ const DlfToken& token,
const std::string& host,
+ const Headers&
sign_headers) const {
+ PAIMON_ASSIGN_OR_RAISE(std::string accept, RequiredHeader(sign_headers,
kOpenApiAcceptHeader));
+ PAIMON_ASSIGN_OR_RAISE(std::string date, RequiredHeader(sign_headers,
kOpenApiDateHeader));
+ std::string content_md5;
+ auto md5_iter = sign_headers.find(kContentMd5Header);
+ if (md5_iter != sign_headers.end()) {
+ content_md5 = md5_iter->second;
+ }
+ std::string content_type;
+ auto type_iter = sign_headers.find(kContentTypeHeader);
+ if (type_iter != sign_headers.end()) {
+ content_type = type_iter->second;
+ }
+ std::string string_to_sign =
+ parameter.method + "\n" + accept + "\n" + content_md5 + "\n" +
content_type + "\n" + date +
+ "\n" + OpenApiCanonicalizedHeaders(sign_headers) +
OpenApiCanonicalizedResource(parameter);
+ PAIMON_ASSIGN_OR_RAISE(Bytes signature,
+ Hmac(EVP_sha1(),
ToBytes(token.GetAccessKeySecret()), string_to_sign));
+ PAIMON_ASSIGN_OR_RAISE(std::string encoded_signature,
Base64Encode(signature));
+ return fmt::format("acs {}:{}", token.GetAccessKeyId(), encoded_signature);
+}
+
+DlfAuthProvider::DlfAuthProvider(std::unique_ptr<DlfTokenLoader> token_loader,
+ const std::optional<DlfToken>& token, const
std::string& host,
+ std::unique_ptr<DlfRequestSigner> signer,
Clock clock)
+ : token_loader_(std::move(token_loader)),
+ token_(token),
+ host_(host),
+ signer_(std::move(signer)),
+ clock_(std::move(clock)) {}
+
+Result<std::unique_ptr<DlfAuthProvider>> DlfAuthProvider::Create(
+ const std::map<std::string, std::string>& options) {
+ PAIMON_ASSIGN_OR_RAISE(std::string uri, RequiredNonEmptyOption(options,
CatalogOptions::URI));
+ std::string region;
+ PAIMON_ASSIGN_OR_RAISE(
+ std::optional<std::string> configured_region,
+ OptionsUtils::GetOptionalValueFromMap<std::string>(options,
CatalogOptions::DLF_REGION));
+ if (configured_region) {
+ if (configured_region->empty()) {
+ return Status::Invalid("option 'dlf.region' must not be empty");
+ }
+ region = configured_region.value();
+ } else {
+ PAIMON_ASSIGN_OR_RAISE(region, ParseRegionFromUri(uri));
+ }
+
+ std::string algorithm;
+ PAIMON_ASSIGN_OR_RAISE(std::optional<std::string> configured_algorithm,
+ OptionsUtils::GetOptionalValueFromMap<std::string>(
+ options,
CatalogOptions::DLF_SIGNING_ALGORITHM));
+ if (configured_algorithm) {
+ algorithm = configured_algorithm.value();
+ } else {
+ algorithm = ParseSigningAlgorithmFromUri(uri);
+ }
+
+ PAIMON_ASSIGN_OR_RAISE(std::optional<std::string> loader_name,
+ OptionsUtils::GetOptionalValueFromMap<std::string>(
+ options, CatalogOptions::DLF_TOKEN_LOADER));
+ PAIMON_ASSIGN_OR_RAISE(std::optional<std::string> token_path,
+ OptionsUtils::GetOptionalValueFromMap<std::string>(
+ options, CatalogOptions::DLF_TOKEN_PATH));
+ if (loader_name) {
+ if (loader_name.value() == "ecs") {
+ PAIMON_ASSIGN_OR_RAISE(std::optional<std::string>
configured_metadata_url,
+
OptionsUtils::GetOptionalValueFromMap<std::string>(
+ options,
CatalogOptions::DLF_TOKEN_ECS_METADATA_URL));
+ std::string metadata_url =
configured_metadata_url.value_or(kDefaultEcsMetadataUrl);
+ PAIMON_ASSIGN_OR_RAISE(std::optional<std::string> role_name,
+
OptionsUtils::GetOptionalValueFromMap<std::string>(
+ options,
CatalogOptions::DLF_TOKEN_ECS_ROLE_NAME));
+ return FromTokenLoader(DlfEcsTokenLoader::Create(metadata_url,
role_name), uri, region,
+ algorithm, std::chrono::system_clock::now);
+ }
+ if (loader_name.value() == "local_file") {
+ PAIMON_ASSIGN_OR_RAISE(std::string path,
+ RequiredNonEmptyOption(options,
CatalogOptions::DLF_TOKEN_PATH));
+ return FromTokenLoader(
+ std::make_unique<DlfLocalFileTokenLoader>(path, 5,
std::chrono::seconds(1)), uri,
+ region, algorithm, std::chrono::system_clock::now);
+ }
+ return Status::NotImplemented(
+ fmt::format("unsupported DLF token loader '{}', supported values
are 'ecs' and "
+ "'local_file'",
+ loader_name.value()));
+ }
+ if (token_path) {
+ if (token_path->empty()) {
+ return Status::Invalid("option 'dlf.token-path' must not be
empty");
+ }
+ return
FromTokenLoader(std::make_unique<DlfLocalFileTokenLoader>(token_path.value(), 5,
+
std::chrono::seconds(1)),
+ uri, region, algorithm,
std::chrono::system_clock::now);
+ }
+
+ PAIMON_ASSIGN_OR_RAISE(std::optional<std::string> access_key_id,
+ OptionsUtils::GetOptionalValueFromMap<std::string>(
+ options, CatalogOptions::DLF_ACCESS_KEY_ID));
+ PAIMON_ASSIGN_OR_RAISE(std::optional<std::string> access_key_secret,
+ OptionsUtils::GetOptionalValueFromMap<std::string>(
+ options,
CatalogOptions::DLF_ACCESS_KEY_SECRET));
+ if (access_key_id && !access_key_id->empty() && access_key_secret &&
+ !access_key_secret->empty()) {
+ PAIMON_ASSIGN_OR_RAISE(std::optional<std::string> security_token,
+
OptionsUtils::GetOptionalValueFromMap<std::string>(
+ options,
CatalogOptions::DLF_SECURITY_TOKEN));
+ DlfToken token(access_key_id.value(), access_key_secret.value(),
security_token,
+ std::nullopt);
+ return FromAccessKey(token, uri, region, algorithm,
std::chrono::system_clock::now);
+ }
+ return Status::Invalid("DLF token path or access key must be configured
for DLF auth");
+}
+
+Result<std::unique_ptr<DlfAuthProvider>> DlfAuthProvider::FromAccessKey(
+ const DlfToken& token, const std::string& uri, const std::string& region,
+ const std::string& signing_algorithm, Clock clock) {
+ if (token.GetAccessKeyId().empty() || token.GetAccessKeySecret().empty()) {
+ return Status::Invalid("DLF access key id and secret must not be
empty");
+ }
+ PAIMON_ASSIGN_OR_RAISE(std::string host, ExtractHost(uri));
+ PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<DlfRequestSigner> signer,
+ CreateSigner(signing_algorithm, region));
+ return std::unique_ptr<DlfAuthProvider>(
+ new DlfAuthProvider(nullptr, token, host, std::move(signer),
std::move(clock)));
+}
+
+Result<std::unique_ptr<DlfAuthProvider>> DlfAuthProvider::FromTokenLoader(
+ std::unique_ptr<DlfTokenLoader> token_loader, const std::string& uri,
const std::string& region,
+ const std::string& signing_algorithm, Clock clock) {
+ if (!token_loader) {
+ return Status::Invalid("DLF token loader must not be null");
+ }
+ PAIMON_ASSIGN_OR_RAISE(std::string host, ExtractHost(uri));
+ PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<DlfRequestSigner> signer,
+ CreateSigner(signing_algorithm, region));
+ return std::unique_ptr<DlfAuthProvider>(new DlfAuthProvider(
+ std::move(token_loader), std::nullopt, host, std::move(signer),
std::move(clock)));
+}
+
+Result<DlfToken>
DlfAuthProvider::GetFreshToken(std::chrono::system_clock::time_point now) const
{
+ std::scoped_lock lock(token_mutex_);
+ if (token_ && !token_->ShouldRefresh(now)) {
+ return token_.value();
+ }
+ if (!token_loader_) {
+ return Status::Invalid("DLF credentials expired and no token loader is
configured");
+ }
+ PAIMON_ASSIGN_OR_RAISE(DlfToken loaded, token_loader_->LoadToken());
+ if (loaded.GetAccessKeyId().empty() ||
loaded.GetAccessKeySecret().empty()) {
+ return Status::Invalid("DLF token loader returned empty access key
credentials");
+ }
+ token_ = loaded;
+ return loaded;
+}
+
+Result<std::map<std::string, std::string>> DlfAuthProvider::MergeAuthHeader(
+ const std::map<std::string, std::string>& base_header,
+ const RestAuthParameter& parameter) const {
+ PAIMON_ASSIGN_OR_RAISE(DlfToken token, GetFreshToken(clock_()));
+ std::chrono::system_clock::time_point signing_time = clock_();
+ PAIMON_ASSIGN_OR_RAISE(
+ DlfRequestSigner::Headers sign_headers,
+ signer_->SignHeaders(parameter.data, signing_time,
token.GetSecurityToken(), host_));
+ PAIMON_ASSIGN_OR_RAISE(std::string authorization,
+ signer_->Authorization(parameter, token, host_,
sign_headers));
+ std::map<std::string, std::string> headers = base_header;
+ for (const auto& [key, value] : sign_headers) {
+ headers[key] = value;
+ }
+ headers[kAuthorizationHeader] = authorization;
+ return headers;
+}
+
+Result<std::string> DlfAuthProvider::ParseRegionFromUri(const std::string&
uri) {
+ static const std::regex
kRegionPattern("(?:pre-)?([a-z]+-[a-z]+(?:-[0-9]+)?)");
+ std::smatch match;
+ if (std::regex_search(uri, match, kRegionPattern) && match.size() > 1 &&
+ !match.str(1).empty()) {
+ return match.str(1);
+ }
+ return Status::Invalid(
+ "could not determine DLF region from option 'dlf.region' or REST
catalog URI");
+}
+
+std::string DlfAuthProvider::ParseSigningAlgorithmFromUri(const std::string&
uri) {
+ std::string lower_uri = StringUtils::ToLowerCase(uri);
+ return lower_uri.find("dlfnext") == std::string::npos ?
DlfDefaultSigner::kIdentifier
+ :
DlfOpenApiSigner::kIdentifier;
+}
+
+Result<std::string> DlfAuthProvider::ExtractHost(const std::string& uri) {
+ std::string host = RestHttpClient::NormalizeUri(uri);
+ std::string lower_uri = StringUtils::ToLowerCase(host);
+ if (StringUtils::StartsWith(lower_uri, "http://")) {
+ host.erase(0, 7);
+ } else if (StringUtils::StartsWith(lower_uri, "https://")) {
+ host.erase(0, 8);
+ }
+ size_t path = host.find('/');
+ if (path != std::string::npos) {
+ host.resize(path);
+ }
+ if (host.empty() || host.find('?') != std::string::npos ||
+ host.find('#') != std::string::npos || host.find('@') !=
std::string::npos) {
+ return Status::Invalid("could not determine DLF signing host from REST
catalog URI");
+ }
+ return host;
+}
+
+} // namespace paimon
diff --git a/src/paimon/rest/dlf_auth.h b/src/paimon/rest/dlf_auth.h
new file mode 100644
index 00000000..c10b655b
--- /dev/null
+++ b/src/paimon/rest/dlf_auth.h
@@ -0,0 +1,208 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+#pragma once
+
+#include <chrono>
+#include <cstdint>
+#include <functional>
+#include <map>
+#include <memory>
+#include <mutex>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "paimon/common/utils/http_client.h"
+#include "paimon/rest/rest_auth.h"
+
+namespace paimon {
+
+/// Access key credentials used to sign DLF REST requests.
+class DlfToken {
+ public:
+ DlfToken(const std::string& access_key_id, const std::string&
access_key_secret,
+ const std::optional<std::string>& security_token,
+ const std::optional<int64_t>& expiration_at_millis);
+
+ static Result<DlfToken> FromJson(const std::string& json);
+
+ const std::string& GetAccessKeyId() const {
+ return access_key_id_;
+ }
+
+ const std::string& GetAccessKeySecret() const {
+ return access_key_secret_;
+ }
+
+ const std::optional<std::string>& GetSecurityToken() const {
+ return security_token_;
+ }
+
+ const std::optional<int64_t>& GetExpirationAtMillis() const {
+ return expiration_at_millis_;
+ }
+
+ bool ShouldRefresh(std::chrono::system_clock::time_point now) const;
+
+ private:
+ std::string access_key_id_;
+ std::string access_key_secret_;
+ std::optional<std::string> security_token_;
+ std::optional<int64_t> expiration_at_millis_;
+};
+
+/// Loads refreshable DLF credentials.
+class DlfTokenLoader {
+ public:
+ virtual ~DlfTokenLoader() = default;
+
+ virtual Result<DlfToken> LoadToken() = 0;
+ virtual std::string Description() const = 0;
+};
+
+/// Loads a DLF STS token from a JSON file.
+class DlfLocalFileTokenLoader : public DlfTokenLoader {
+ public:
+ DlfLocalFileTokenLoader(const std::string& token_file_path, int32_t
max_attempts,
+ std::chrono::milliseconds retry_delay);
+
+ Result<DlfToken> LoadToken() override;
+ std::string Description() const override;
+
+ private:
+ std::string token_file_path_;
+ int32_t max_attempts_;
+ std::chrono::milliseconds retry_delay_;
+};
+
+/// Loads a DLF STS token from the Alibaba Cloud ECS metadata service.
+class DlfEcsTokenLoader : public DlfTokenLoader {
+ public:
+ DlfEcsTokenLoader(const std::string& metadata_url, const
std::optional<std::string>& role_name,
+ std::unique_ptr<HttpClient> http_client);
+
+ static std::unique_ptr<DlfEcsTokenLoader> Create(const std::string&
metadata_url,
+ const
std::optional<std::string>& role_name);
+
+ Result<DlfToken> LoadToken() override;
+ std::string Description() const override;
+
+ private:
+ Result<std::string> Get(const std::string& url) const;
+
+ std::string metadata_url_;
+ std::optional<std::string> role_name_;
+ std::unique_ptr<HttpClient> http_client_;
+};
+
+/// Signs a DLF REST request using one of the endpoint-specific algorithms.
+class DlfRequestSigner {
+ public:
+ using Headers = std::map<std::string, std::string>;
+
+ virtual ~DlfRequestSigner() = default;
+
+ virtual Result<Headers> SignHeaders(const std::string& body,
+ std::chrono::system_clock::time_point
now,
+ const std::optional<std::string>&
security_token,
+ const std::string& host) const = 0;
+
+ virtual Result<std::string> Authorization(const RestAuthParameter&
parameter,
+ const DlfToken& token, const
std::string& host,
+ const Headers& sign_headers)
const = 0;
+};
+
+/// DLF4-HMAC-SHA256 signer used by the default DLF VPC endpoint.
+class DlfDefaultSigner : public DlfRequestSigner {
+ public:
+ static constexpr const char* kIdentifier = "default";
+
+ explicit DlfDefaultSigner(const std::string& region);
+
+ Result<Headers> SignHeaders(const std::string& body,
std::chrono::system_clock::time_point now,
+ const std::optional<std::string>&
security_token,
+ const std::string& host) const override;
+
+ Result<std::string> Authorization(const RestAuthParameter& parameter,
const DlfToken& token,
+ const std::string& host,
+ const Headers& sign_headers) const
override;
+
+ private:
+ std::string region_;
+};
+
+/// ROA HMAC-SHA1 signer used by DlfNext OpenAPI endpoints.
+class DlfOpenApiSigner : public DlfRequestSigner {
+ public:
+ static constexpr const char* kIdentifier = "openapi";
+
+ Result<Headers> SignHeaders(const std::string& body,
std::chrono::system_clock::time_point now,
+ const std::optional<std::string>&
security_token,
+ const std::string& host) const override;
+
+ Result<std::string> Authorization(const RestAuthParameter& parameter,
const DlfToken& token,
+ const std::string& host,
+ const Headers& sign_headers) const
override;
+};
+
+/// Generates DLF authentication headers and refreshes expiring credentials.
+class DlfAuthProvider : public AuthProvider {
+ public:
+ using Clock = std::function<std::chrono::system_clock::time_point()>;
+
+ static Result<std::unique_ptr<DlfAuthProvider>> Create(
+ const std::map<std::string, std::string>& options);
+
+ static Result<std::unique_ptr<DlfAuthProvider>> FromAccessKey(
+ const DlfToken& token, const std::string& uri, const std::string&
region,
+ const std::string& signing_algorithm, Clock clock);
+
+ static Result<std::unique_ptr<DlfAuthProvider>> FromTokenLoader(
+ std::unique_ptr<DlfTokenLoader> token_loader, const std::string& uri,
+ const std::string& region, const std::string& signing_algorithm, Clock
clock);
+
+ Result<std::map<std::string, std::string>> MergeAuthHeader(
+ const std::map<std::string, std::string>& base_header,
+ const RestAuthParameter& parameter) const override;
+
+ bool AllowsRedirects() const override {
+ return false;
+ }
+
+ static Result<std::string> ParseRegionFromUri(const std::string& uri);
+ static std::string ParseSigningAlgorithmFromUri(const std::string& uri);
+ static Result<std::string> ExtractHost(const std::string& uri);
+
+ private:
+ DlfAuthProvider(std::unique_ptr<DlfTokenLoader> token_loader,
+ const std::optional<DlfToken>& token, const std::string&
host,
+ std::unique_ptr<DlfRequestSigner> signer, Clock clock);
+
+ Result<DlfToken> GetFreshToken(std::chrono::system_clock::time_point now)
const;
+
+ std::unique_ptr<DlfTokenLoader> token_loader_;
+ mutable std::optional<DlfToken> token_;
+ std::string host_;
+ std::unique_ptr<DlfRequestSigner> signer_;
+ Clock clock_;
+ mutable std::mutex token_mutex_;
+};
+
+} // namespace paimon
diff --git a/src/paimon/rest/dlf_auth_test.cpp
b/src/paimon/rest/dlf_auth_test.cpp
new file mode 100644
index 00000000..afb9cec5
--- /dev/null
+++ b/src/paimon/rest/dlf_auth_test.cpp
@@ -0,0 +1,468 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 "paimon/rest/dlf_auth.h"
+
+#include <atomic>
+#include <chrono>
+#include <fstream>
+#include <memory>
+#include <mutex>
+#include <optional>
+#include <string>
+#include <thread>
+#include <utility>
+#include <vector>
+
+#include "gtest/gtest.h"
+#include "paimon/catalog_options.h"
+#include "paimon/testing/utils/testharness.h"
+
+namespace paimon::test {
+
+namespace {
+
+using StringMap = std::map<std::string, std::string>;
+
+std::chrono::system_clock::time_point FixedTime() {
+ return std::chrono::system_clock::from_time_t(1744775086);
+}
+
+class SequenceTokenLoader : public DlfTokenLoader {
+ public:
+ explicit SequenceTokenLoader(
+ std::vector<DlfToken> tokens,
+ std::chrono::milliseconds load_delay = std::chrono::milliseconds(0))
+ : tokens_(std::move(tokens)), load_delay_(load_delay) {}
+
+ Result<DlfToken> LoadToken() override {
+ std::this_thread::sleep_for(load_delay_);
+ int32_t index = load_count_.fetch_add(1);
+ if (index >= static_cast<int32_t>(tokens_.size())) {
+ return Status::Invalid("test token loader exhausted");
+ }
+ return tokens_[index];
+ }
+
+ std::string Description() const override {
+ return "test sequence";
+ }
+
+ int32_t GetLoadCount() const {
+ return load_count_.load();
+ }
+
+ private:
+ std::vector<DlfToken> tokens_;
+ std::chrono::milliseconds load_delay_;
+ std::atomic<int32_t> load_count_{0};
+};
+
+class MockEcsHttpClient : public HttpClient {
+ public:
+ explicit MockEcsHttpClient(const std::string& metadata_url, bool
direct_token = false)
+ : metadata_url_(metadata_url), direct_token_(direct_token) {}
+
+ Result<HttpResponse> Execute(const HttpRequest& request,
+ const HttpBodyConsumer& consumer) const
override {
+ last_request_timeout_ms_.store(request.request_timeout_ms);
+ HttpResponse response;
+ response.status_code = 200;
+ std::string body;
+ if (request.url == metadata_url_) {
+ if (direct_token_) {
+ token_requests_.fetch_add(1);
+ body = R"({"AccessKeyId":"ecs-ak","AccessKeySecret":"ecs-sk",)"
+
R"("SecurityToken":"ecs-sts","Expiration":"2027-04-16T05:44:46Z"})";
+ } else {
+ role_requests_.fetch_add(1);
+ body = " test-role\n";
+ }
+ } else if (request.url == metadata_url_ + "test-role") {
+ token_requests_.fetch_add(1);
+ body = R"({"AccessKeyId":"ecs-ak","AccessKeySecret":"ecs-sk",)"
+
R"("SecurityToken":"ecs-sts","Expiration":"2027-04-16T05:44:46Z"})";
+ } else {
+ response.status_code = 404;
+ }
+ if (!body.empty()) {
+ PAIMON_RETURN_NOT_OK(consumer(body.data(),
static_cast<int64_t>(body.size())));
+ response.body_size = static_cast<int64_t>(body.size());
+ }
+ return response;
+ }
+
+ int32_t GetRoleRequestCount() const {
+ return role_requests_.load();
+ }
+
+ int32_t GetTokenRequestCount() const {
+ return token_requests_.load();
+ }
+
+ int64_t GetLastRequestTimeoutMillis() const {
+ return last_request_timeout_ms_.load();
+ }
+
+ private:
+ std::string metadata_url_;
+ bool direct_token_;
+ mutable std::atomic<int32_t> role_requests_{0};
+ mutable std::atomic<int32_t> token_requests_{0};
+ mutable std::atomic<int64_t> last_request_timeout_ms_{-1};
+};
+
+class FailingEcsHttpClient : public HttpClient {
+ public:
+ Result<HttpResponse> Execute(const HttpRequest&, const HttpBodyConsumer&)
const override {
+ return Status::IOError("connection refused");
+ }
+};
+
+} // namespace
+
+TEST(DlfDefaultSignerTest, SignsJavaCompatibleRequest) {
+ DlfDefaultSigner signer("cn-beijing");
+ const std::string body = R"({"name":"t1"})";
+ DlfToken token("YourAccessKeyId", "YourAccessKeySecret", "securityToken",
std::nullopt);
+ RestAuthParameter parameter =
+ RestAuthParameter::Create("POST", "/v1/wh/databases/db/tables",
+ {{"warehouse", "my instance"}, {"branch",
"main"}}, body);
+
+ ASSERT_OK_AND_ASSIGN(DlfRequestSigner::Headers headers,
+ signer.SignHeaders(body, FixedTime(),
token.GetSecurityToken(), "unused"));
+ ASSERT_EQ("20250416T034446Z", headers.at("x-dlf-date"));
+ ASSERT_EQ("Od9T1x3c2+JusJPFMpXe9Q==", headers.at("Content-MD5"));
+ ASSERT_EQ("application/json", headers.at("Content-Type"));
+ ASSERT_EQ("UNSIGNED-PAYLOAD", headers.at("x-dlf-content-sha256"));
+ ASSERT_EQ("v1", headers.at("x-dlf-version"));
+ ASSERT_EQ("securityToken", headers.at("x-dlf-security-token"));
+
+ ASSERT_OK_AND_ASSIGN(std::string authorization,
+ signer.Authorization(parameter, token, "unused",
headers));
+ ASSERT_EQ(
+ "DLF4-HMAC-SHA256 Credential=YourAccessKeyId/20250416/cn-beijing/"
+ "DlfNext/aliyun_v4_request,Signature="
+ "22594f8bbb8bb0ec296ced6003b7ffdf7022a8ca3815da5b53090daa11a06558",
+ authorization);
+}
+
+TEST(DlfDefaultSignerTest, MatchesJavaGoldenAuthorization) {
+ // Mirrors Java DLFAuthSignatureTest#testGetAuthorization.
+ DlfDefaultSigner signer("cn-hangzhou");
+ const std::string body = R"({"name":"database","options":{"a":"b"}})";
+ DlfToken token("access-key-id", "access-key-secret", "securityToken",
std::nullopt);
+ RestAuthParameter parameter = RestAuthParameter::Create("POST",
"/v1/paimon/databases",
+ {{"k1", "v1"},
{"k2", "v2"}}, body);
+ const std::chrono::system_clock::time_point signing_time =
+ std::chrono::system_clock::from_time_t(1701605532);
+
+ ASSERT_OK_AND_ASSIGN(DlfRequestSigner::Headers headers,
+ signer.SignHeaders(body, signing_time,
token.GetSecurityToken(), "host"));
+ ASSERT_OK_AND_ASSIGN(std::string authorization,
+ signer.Authorization(parameter, token, "host",
headers));
+ ASSERT_EQ(
+ "DLF4-HMAC-SHA256 Credential=access-key-id/20231203/cn-hangzhou/"
+ "DlfNext/aliyun_v4_request,Signature="
+ "c72caf1d40b55b1905d891ee3e3de48a2f8bebefa7e39e4f277acc93c269c5e3",
+ authorization);
+}
+
+TEST(DlfDefaultSignerTest, OmitsBodyAndSecurityTokenHeadersWhenAbsent) {
+ DlfDefaultSigner signer("cn-hangzhou");
+ ASSERT_OK_AND_ASSIGN(DlfRequestSigner::Headers headers,
+ signer.SignHeaders("", FixedTime(), std::nullopt,
"unused"));
+ ASSERT_EQ(3, headers.size());
+ ASSERT_EQ(0, headers.count("Content-MD5"));
+ ASSERT_EQ(0, headers.count("Content-Type"));
+ ASSERT_EQ(0, headers.count("x-dlf-security-token"));
+}
+
+TEST(DlfOpenApiSignerTest, SignsJavaCompatibleRequest) {
+ DlfOpenApiSigner signer;
+ const std::string body =
R"({"CategoryName":"test","CategoryType":"UNSTRUCTURED"})";
+ const std::string host = "dlfnext.cn-beijing.aliyuncs.com";
+ DlfToken token("YourAccessKeyId", "YourAccessKeySecret", "securityToken",
std::nullopt);
+ RestAuthParameter parameter =
+ RestAuthParameter::Create("POST",
"/llm-p2e4XXXXXXXXsvtn/datacenter/category", {}, body);
+
+ ASSERT_OK_AND_ASSIGN(DlfRequestSigner::Headers headers,
+ signer.SignHeaders(body, FixedTime(),
token.GetSecurityToken(), host));
+ headers["x-acs-signature-nonce"] = "ef34aae7-7bd2-413d-a541-680cd2c48538";
+ ASSERT_EQ("Wed, 16 Apr 2025 03:44:46 GMT", headers.at("Date"));
+ ASSERT_EQ("q2qaEcR4P47+Z7CUzHRTBw==", headers.at("Content-MD5"));
+ ASSERT_EQ("application/json", headers.at("Accept"));
+ ASSERT_EQ("application/json", headers.at("Content-Type"));
+ ASSERT_EQ(host, headers.at("Host"));
+ ASSERT_EQ("HMAC-SHA1", headers.at("x-acs-signature-method"));
+ ASSERT_EQ("1.0", headers.at("x-acs-signature-version"));
+ ASSERT_EQ("2026-01-18", headers.at("x-acs-version"));
+ ASSERT_EQ("securityToken", headers.at("x-acs-security-token"));
+
+ ASSERT_OK_AND_ASSIGN(std::string authorization,
+ signer.Authorization(parameter, token, host,
headers));
+ ASSERT_EQ("acs YourAccessKeyId:wX4CDPSCtfgYkxdK9tJIO3ez5VI=",
authorization);
+}
+
+TEST(DlfOpenApiSignerTest, DecodesPathAndQueryValuesBeforeSigning) {
+ DlfOpenApiSigner signer;
+ DlfToken token("ak", "sk", std::nullopt, std::nullopt);
+ RestAuthParameter parameter = RestAuthParameter::Create(
+ "GET", "/v1/%24snapshots", {{"z", ""}, {"name", "hello world"}}, "");
+ ASSERT_OK_AND_ASSIGN(DlfRequestSigner::Headers headers,
+ signer.SignHeaders("", FixedTime(), std::nullopt,
"host"));
+ headers["x-acs-signature-nonce"] = "fixed-nonce";
+
+ ASSERT_OK_AND_ASSIGN(std::string authorization,
+ signer.Authorization(parameter, token, "host",
headers));
+ ASSERT_EQ("acs ak:vD+M7291KKoOTvt2gQuD6jPDlw8=", authorization);
+ ASSERT_EQ(0, headers.count("Content-MD5"));
+ ASSERT_EQ(0, headers.count("Content-Type"));
+}
+
+TEST(DlfTokenTest, ParsesExpirationAndRefreshBoundary) {
+ ASSERT_OK_AND_ASSIGN(
+ DlfToken token,
+
DlfToken::FromJson(R"({"AccessKeyId":"ak","AccessKeySecret":"sk","SecurityToken":"sts",)"
+
R"("Expiration":"2025-04-16T05:44:46Z","Ignored":"value"})"));
+ ASSERT_EQ("ak", token.GetAccessKeyId());
+ ASSERT_EQ("sk", token.GetAccessKeySecret());
+ ASSERT_EQ(std::optional<std::string>("sts"), token.GetSecurityToken());
+ ASSERT_EQ(std::optional<int64_t>(1744782286000),
token.GetExpirationAtMillis());
+ ASSERT_FALSE(token.ShouldRefresh(FixedTime() + std::chrono::hours(1)));
+ ASSERT_TRUE(
+ token.ShouldRefresh(FixedTime() + std::chrono::hours(1) +
std::chrono::milliseconds(1)));
+
+ DlfToken permanent("ak", "sk", std::nullopt, std::nullopt);
+ ASSERT_FALSE(permanent.ShouldRefresh(FixedTime() +
std::chrono::hours(100000)));
+}
+
+TEST(DlfTokenTest, ParseFailureDoesNotLeakCredentials) {
+ const std::string secret = "STSSECRET_AKID_9999";
+ Status status =
+ DlfToken::FromJson(R"({"AccessKeyId":"ak","AccessKeySecret":")" +
secret).status();
+ ASSERT_FALSE(status.ok());
+ ASSERT_EQ(std::string::npos, status.ToString().find(secret));
+}
+
+TEST(DlfLocalFileTokenLoaderTest, LoadsTokenAndRedactsMalformedContent) {
+ std::unique_ptr<UniqueTestDirectory> test_dir =
UniqueTestDirectory::Create();
+ ASSERT_NE(nullptr, test_dir);
+ const std::string path = test_dir->Str() + "/dlf-token.json";
+ {
+ std::ofstream file(path);
+ file << R"({"AccessKeyId":"file-ak","AccessKeySecret":"file-sk",)"
+
R"("SecurityToken":"file-sts","Expiration":"2027-04-16T05:44:46Z"})";
+ }
+ DlfLocalFileTokenLoader loader(path, 1, std::chrono::milliseconds(0));
+ ASSERT_OK_AND_ASSIGN(DlfToken token, loader.LoadToken());
+ ASSERT_EQ("file-ak", token.GetAccessKeyId());
+ ASSERT_EQ(std::optional<std::string>("file-sts"),
token.GetSecurityToken());
+
+ std::map<std::string, std::string> options = {
+ {CatalogOptions::URI, "https://cn-hangzhou-vpc.dlf.aliyuncs.com"},
+ {CatalogOptions::TOKEN_PROVIDER, "dlf"},
+ {CatalogOptions::DLF_TOKEN_PATH, path},
+ {CatalogOptions::DLF_ACCESS_KEY_ID, "ignored-ak"},
+ {CatalogOptions::DLF_ACCESS_KEY_SECRET, "ignored-sk"}};
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<AuthProvider> provider,
AuthProvider::Create(options));
+ RestAuthParameter parameter = RestAuthParameter::Create("GET",
"/v1/config", {}, "");
+ ASSERT_OK_AND_ASSIGN(StringMap headers, provider->MergeAuthHeader({},
parameter));
+ ASSERT_NE(std::string::npos,
headers.at("Authorization").find("Credential=file-ak/"));
+
+ options[CatalogOptions::DLF_TOKEN_LOADER] = "local_file";
+ ASSERT_OK_AND_ASSIGN(provider, AuthProvider::Create(options));
+ ASSERT_OK(provider->MergeAuthHeader({}, parameter));
+
+ const std::string secret = "FILE_SECRET_9999";
+ {
+ std::ofstream file(path);
+ file << R"({"AccessKeyId":"ak","AccessKeySecret":")" << secret;
+ }
+ Status status = loader.LoadToken().status();
+ ASSERT_FALSE(status.ok());
+ ASSERT_EQ(std::string::npos, status.ToString().find(secret));
+}
+
+TEST(DlfEcsTokenLoaderTest, DiscoversRoleOnceAndRefreshesToken) {
+ const std::string metadata_url = "http://100.100.100.200/metadata/";
+ auto http_client = std::make_unique<MockEcsHttpClient>(metadata_url);
+ MockEcsHttpClient* http_client_ptr = http_client.get();
+ DlfEcsTokenLoader loader(metadata_url, std::nullopt,
std::move(http_client));
+
+ ASSERT_OK_AND_ASSIGN(DlfToken first, loader.LoadToken());
+ ASSERT_OK_AND_ASSIGN(DlfToken second, loader.LoadToken());
+ ASSERT_EQ("ecs-ak", first.GetAccessKeyId());
+ ASSERT_EQ("ecs-ak", second.GetAccessKeyId());
+ ASSERT_EQ(1, http_client_ptr->GetRoleRequestCount());
+ ASSERT_EQ(2, http_client_ptr->GetTokenRequestCount());
+ ASSERT_EQ(180000, http_client_ptr->GetLastRequestTimeoutMillis());
+}
+
+TEST(DlfEcsTokenLoaderTest, ExplicitEmptyRoleUsesMetadataUrlAsTokenEndpoint) {
+ const std::string metadata_url = "http://100.100.100.200/metadata/token";
+ auto http_client = std::make_unique<MockEcsHttpClient>(metadata_url,
/*direct_token=*/true);
+ MockEcsHttpClient* http_client_ptr = http_client.get();
+ DlfEcsTokenLoader loader(metadata_url, std::string(""),
std::move(http_client));
+
+ ASSERT_OK_AND_ASSIGN(DlfToken token, loader.LoadToken());
+ ASSERT_EQ("ecs-ak", token.GetAccessKeyId());
+ ASSERT_EQ(0, http_client_ptr->GetRoleRequestCount());
+ ASSERT_EQ(1, http_client_ptr->GetTokenRequestCount());
+ ASSERT_EQ(180000, http_client_ptr->GetLastRequestTimeoutMillis());
+}
+
+TEST(DlfEcsTokenLoaderTest, PreservesTransportFailureDetail) {
+ DlfEcsTokenLoader loader("http://100.100.100.200/metadata/token",
std::string(""),
+ std::make_unique<FailingEcsHttpClient>());
+ Status status = loader.LoadToken().status();
+ ASSERT_NOK_WITH_MSG(status, "failed to request DLF credentials from ECS
metadata service");
+ ASSERT_NOK_WITH_MSG(status, "connection refused");
+}
+
+TEST(DlfAuthProviderTest, RefreshesWithinSafeWindow) {
+ std::atomic<int64_t> now_seconds{1744775086};
+ std::vector<DlfToken> tokens;
+ tokens.emplace_back("ak-1", "sk-1", std::nullopt, 1744782286000);
+ tokens.emplace_back("ak-2", "sk-2", std::nullopt, std::nullopt);
+ auto loader = std::make_unique<SequenceTokenLoader>(tokens);
+ SequenceTokenLoader* loader_ptr = loader.get();
+ ASSERT_OK_AND_ASSIGN(
+ std::unique_ptr<DlfAuthProvider> provider,
+ DlfAuthProvider::FromTokenLoader(
+ std::move(loader), "https://cn-beijing-vpc.dlf.aliyuncs.com",
"cn-beijing", "default",
+ [&] { return
std::chrono::system_clock::from_time_t(now_seconds.load()); }));
+ RestAuthParameter parameter = RestAuthParameter::Create("GET",
"/v1/config", {}, "");
+
+ ASSERT_OK_AND_ASSIGN(StringMap first, provider->MergeAuthHeader({},
parameter));
+ ASSERT_NE(std::string::npos,
first.at("Authorization").find("Credential=ak-1/"));
+ ASSERT_OK(provider->MergeAuthHeader({}, parameter));
+ ASSERT_EQ(1, loader_ptr->GetLoadCount());
+
+ now_seconds.fetch_add(3601);
+ ASSERT_OK_AND_ASSIGN(StringMap refreshed, provider->MergeAuthHeader({},
parameter));
+ ASSERT_NE(std::string::npos,
refreshed.at("Authorization").find("Credential=ak-2/"));
+ ASSERT_EQ(2, loader_ptr->GetLoadCount());
+}
+
+TEST(DlfAuthProviderTest, ConcurrentFirstUseLoadsTokenOnce) {
+ auto loader = std::make_unique<SequenceTokenLoader>(
+ std::vector<DlfToken>{
+ DlfToken("concurrent-ak", "concurrent-sk", std::nullopt,
std::nullopt)},
+ std::chrono::milliseconds(10));
+ SequenceTokenLoader* loader_ptr = loader.get();
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<DlfAuthProvider> provider,
+ DlfAuthProvider::FromTokenLoader(std::move(loader),
+
"https://cn-beijing-vpc.dlf.aliyuncs.com",
+ "cn-beijing",
"default", FixedTime));
+ RestAuthParameter parameter = RestAuthParameter::Create("GET",
"/v1/config", {}, "");
+ std::vector<std::thread> threads;
+ std::vector<Status> statuses;
+ std::mutex statuses_mutex;
+ for (int32_t i = 0; i < 16; ++i) {
+ threads.emplace_back([&] {
+ Status status = provider->MergeAuthHeader({}, parameter).status();
+ std::scoped_lock lock(statuses_mutex);
+ statuses.push_back(status);
+ });
+ }
+ for (std::thread& thread : threads) {
+ thread.join();
+ }
+ ASSERT_EQ(16, statuses.size());
+ for (const Status& status : statuses) {
+ ASSERT_OK(status);
+ }
+ ASSERT_EQ(1, loader_ptr->GetLoadCount());
+}
+
+TEST(DlfAuthProviderTest, SelectsEndpointSignerAndCredentialSource) {
+ std::map<std::string, std::string> options = {
+ {CatalogOptions::URI, "https://dlfnext.cn-hangzhou.aliyuncs.com"},
+ {CatalogOptions::TOKEN_PROVIDER, "dlf"},
+ {CatalogOptions::DLF_ACCESS_KEY_ID, "ak"},
+ {CatalogOptions::DLF_ACCESS_KEY_SECRET, "sk"},
+ {CatalogOptions::DLF_SECURITY_TOKEN, "sts"}};
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<AuthProvider> provider,
AuthProvider::Create(options));
+ RestAuthParameter parameter = RestAuthParameter::Create("GET",
"/v1/config", {}, "");
+ ASSERT_OK_AND_ASSIGN(StringMap headers,
provider->MergeAuthHeader({{"Authorization", "old"},
+
{"x-acs-version", "old"},
+
{"custom-header", "kept"}},
+
parameter));
+ ASSERT_NE(std::string::npos, headers.at("Authorization").find("acs ak:"));
+ ASSERT_EQ("2026-01-18", headers.at("x-acs-version"));
+ ASSERT_EQ("sts", headers.at("x-acs-security-token"));
+ ASSERT_EQ("kept", headers.at("custom-header"));
+ ASSERT_FALSE(provider->AllowsRedirects());
+
+ BearTokenAuthProvider bear_provider("token");
+ ASSERT_TRUE(bear_provider.AllowsRedirects());
+
+ ASSERT_EQ("openapi",
DlfAuthProvider::ParseSigningAlgorithmFromUri(options.at("uri")));
+ ASSERT_EQ("default", DlfAuthProvider::ParseSigningAlgorithmFromUri(
+ "https://cn-hangzhou-vpc.dlf.aliyuncs.com"));
+ ASSERT_OK_AND_ASSIGN(std::string region,
+
DlfAuthProvider::ParseRegionFromUri(options.at("uri")));
+ ASSERT_EQ("cn-hangzhou", region);
+ ASSERT_OK_AND_ASSIGN(std::string host,
+
DlfAuthProvider::ExtractHost("https://example.com:8443/prefix"));
+ ASSERT_EQ("example.com:8443", host);
+}
+
+TEST(DlfAuthProviderTest, NormalizesSigningHostLikeTransport) {
+ std::map<std::string, std::string> options = {
+ {CatalogOptions::URI, " https://dlfnext.cn-hangzhou.aliyuncs.com "},
+ {CatalogOptions::TOKEN_PROVIDER, "dlf"},
+ {CatalogOptions::DLF_ACCESS_KEY_ID, "ak"},
+ {CatalogOptions::DLF_ACCESS_KEY_SECRET, "sk"}};
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<AuthProvider> provider,
AuthProvider::Create(options));
+ RestAuthParameter parameter = RestAuthParameter::Create("GET",
"/v1/config", {}, "");
+ ASSERT_OK_AND_ASSIGN(StringMap headers, provider->MergeAuthHeader({},
parameter));
+ ASSERT_EQ("dlfnext.cn-hangzhou.aliyuncs.com", headers.at("Host"));
+}
+
+TEST(DlfAuthProviderTest, RejectsIncompleteOrUnknownConfiguration) {
+ const std::map<std::string, std::string> base = {
+ {CatalogOptions::URI, "https://dlfnext.cn-hangzhou.aliyuncs.com"},
+ {CatalogOptions::TOKEN_PROVIDER, "dlf"}};
+ ASSERT_NOK_WITH_MSG(AuthProvider::Create(base).status(), "token path or
access key");
+
+ std::map<std::string, std::string> options = base;
+ options[CatalogOptions::DLF_TOKEN_LOADER] = "ecs";
+ options[CatalogOptions::DLF_TOKEN_ECS_METADATA_URL] = "http://metadata/";
+ options[CatalogOptions::DLF_TOKEN_ECS_ROLE_NAME] = "role";
+ ASSERT_OK(AuthProvider::Create(options));
+
+ options = base;
+ options[CatalogOptions::DLF_TOKEN_LOADER] = "unknown";
+ ASSERT_NOK_WITH_MSG(AuthProvider::Create(options).status(), "unsupported
DLF token loader");
+
+ options = base;
+ options[CatalogOptions::DLF_ACCESS_KEY_ID] = "ak";
+ options[CatalogOptions::DLF_ACCESS_KEY_SECRET] = "sk";
+ options[CatalogOptions::DLF_SIGNING_ALGORITHM] = "unknown";
+ ASSERT_NOK_WITH_MSG(AuthProvider::Create(options).status(),
+ "unsupported DLF signing algorithm");
+
+ options[CatalogOptions::DLF_SIGNING_ALGORITHM] = "default";
+ options[CatalogOptions::URI] = "http://127.0.0.1:8080";
+ ASSERT_NOK_WITH_MSG(AuthProvider::Create(options).status(), "DLF region");
+}
+
+} // namespace paimon::test
diff --git a/src/paimon/rest/rest_api.cpp b/src/paimon/rest/rest_api.cpp
index 393a0938..437484ef 100644
--- a/src/paimon/rest/rest_api.cpp
+++ b/src/paimon/rest/rest_api.cpp
@@ -81,11 +81,12 @@ Result<std::unique_ptr<RestApi>> RestApi::Create(const
std::map<std::string, std
RestAuthParameter::Create("GET", ResourcePaths::Config(),
query_params, "");
PAIMON_ASSIGN_OR_RAISE(StringMap headers,
auth_provider->MergeAuthHeader(base_headers,
auth_parameter));
- PAIMON_ASSIGN_OR_RAISE(
- RestHttpClient::Response response,
- client->Execute("GET", ResourcePaths::Config(), query_params,
headers, ""));
+ bool follow_redirects = auth_provider->AllowsRedirects();
+ PAIMON_ASSIGN_OR_RAISE(RestHttpClient::Response response,
+ client->Execute("GET", ResourcePaths::Config(),
query_params,
+ headers, "", follow_redirects));
if (!response.IsSuccessful()) {
- return ErrorToStatus(response);
+ return ErrorToStatus(response, follow_redirects);
}
ConfigResponse config;
PAIMON_RETURN_NOT_OK(ParseResponseBody(response.body,
ResourcePaths::Config(), &config));
@@ -109,7 +110,19 @@ Result<std::unique_ptr<RestApi>> RestApi::Create(const
std::map<std::string, std
ResourcePaths(prefix)));
}
-Status RestApi::ErrorToStatus(const RestHttpClient::Response& response) {
+Status RestApi::ErrorToStatus(const RestHttpClient::Response& response, bool
follow_redirects) {
+ if (!follow_redirects && response.code >= 300 && response.code <
HttpStatus::kBadRequest) {
+ std::string message = fmt::format(
+ "rest endpoint returned redirect status {}, which is not followed
for "
+ "signed requests",
+ response.code);
+ std::string request_id = RestUtil::ExtractRequestId(response.headers);
+ if (request_id != RestUtil::kUnknownRequestId) {
+ message += fmt::format(" requestId:{}", request_id);
+ }
+ return Status::IOError(message).WithDetail(
+ std::make_shared<RestErrorDetail>(response.code));
+ }
// The code of the parsed error body takes precedence over the http
status, which
// a gateway may have rewritten.
int64_t code = response.code;
@@ -188,10 +201,12 @@ Result<RestHttpClient::Response> RestApi::Execute(
}
PAIMON_ASSIGN_OR_RAISE(StringMap headers,
auth_provider_->MergeAuthHeader(request_headers,
auth_parameter));
- PAIMON_ASSIGN_OR_RAISE(RestHttpClient::Response response,
- client_->Execute(method, path, query_params,
headers, body));
+ bool follow_redirects = auth_provider_->AllowsRedirects();
+ PAIMON_ASSIGN_OR_RAISE(
+ RestHttpClient::Response response,
+ client_->Execute(method, path, query_params, headers, body,
follow_redirects));
if (!response.IsSuccessful()) {
- return ErrorToStatus(response);
+ return ErrorToStatus(response, follow_redirects);
}
return response;
}
diff --git a/src/paimon/rest/rest_api.h b/src/paimon/rest/rest_api.h
index c2394ced..f23fe135 100644
--- a/src/paimon/rest/rest_api.h
+++ b/src/paimon/rest/rest_api.h
@@ -105,9 +105,11 @@ class RestApi {
/// Maps a non-successful http response to a status: 404 becomes
`NotExist`, 409
/// becomes `Exist`, 400 becomes `Invalid`, 501 becomes `NotImplemented`
and the
- /// other codes become `IOError`. The status carries a `RestErrorDetail`
with the
- /// mapped code.
- static Status ErrorToStatus(const RestHttpClient::Response& response);
+ /// other codes become `IOError`. A redirect returned while
`follow_redirects` is
+ /// false is reported as deliberately rejected for a signed request. The
status
+ /// carries a `RestErrorDetail` with the mapped code.
+ static Status ErrorToStatus(const RestHttpClient::Response& response,
+ bool follow_redirects = true);
private:
RestApi(std::unique_ptr<RestHttpClient> client,
std::unique_ptr<AuthProvider> auth_provider,
diff --git a/src/paimon/rest/rest_auth.cpp b/src/paimon/rest/rest_auth.cpp
index 65d6d755..30a1869a 100644
--- a/src/paimon/rest/rest_auth.cpp
+++ b/src/paimon/rest/rest_auth.cpp
@@ -20,6 +20,7 @@
#include "paimon/catalog_options.h"
#include "paimon/common/utils/string_utils.h"
#include "paimon/common/utils/url_utils.h"
+#include "paimon/rest/dlf_auth.h"
namespace paimon {
@@ -52,8 +53,8 @@ Result<std::unique_ptr<AuthProvider>> AuthProvider::Create(
return Status::Invalid(fmt::format("option '{}' must be configured for
the rest catalog",
CatalogOptions::TOKEN_PROVIDER));
}
- // Matched leniently in lower case; other clients may match the provider
name
- // case-sensitively, so only the exact "bear" spelling is portable.
+ // Matched leniently in lower case; other clients may match provider names
+ // case-sensitively, so the exact "bear" and "dlf" spellings are portable.
std::string provider = StringUtils::ToLowerCase(provider_iter->second);
if (provider == "bear") {
auto token_iter = options.find(CatalogOptions::TOKEN);
@@ -64,8 +65,11 @@ Result<std::unique_ptr<AuthProvider>> AuthProvider::Create(
}
return std::make_unique<BearTokenAuthProvider>(token_iter->second);
}
- return Status::NotImplemented(
- fmt::format("unsupported token provider: {}, only 'bear' is supported
for now", provider));
+ if (provider == "dlf") {
+ return DlfAuthProvider::Create(options);
+ }
+ return Status::NotImplemented(fmt::format(
+ "unsupported token provider: {}, supported providers are 'bear' and
'dlf'", provider));
}
} // namespace paimon
diff --git a/src/paimon/rest/rest_auth.h b/src/paimon/rest/rest_auth.h
index 22da08d9..72120d24 100644
--- a/src/paimon/rest/rest_auth.h
+++ b/src/paimon/rest/rest_auth.h
@@ -52,6 +52,11 @@ class AuthProvider {
const std::map<std::string, std::string>& base_header,
const RestAuthParameter& parameter) const = 0;
+ /// Whether the transport may follow a redirect without regenerating auth
headers.
+ virtual bool AllowsRedirects() const {
+ return true;
+ }
+
/// Creates the provider configured by `CatalogOptions::TOKEN_PROVIDER`.
static Result<std::unique_ptr<AuthProvider>> Create(
const std::map<std::string, std::string>& options);
diff --git a/src/paimon/rest/rest_catalog_test.cpp
b/src/paimon/rest/rest_catalog_test.cpp
index fda03cc4..f1bb93aa 100644
--- a/src/paimon/rest/rest_catalog_test.cpp
+++ b/src/paimon/rest/rest_catalog_test.cpp
@@ -414,7 +414,7 @@ TEST_F(RestCatalogTest, CreateRejectsInvalidOptions) {
ASSERT_NOK_WITH_MSG(CreateRestCatalog().status(), "'token.provider' must
be configured");
options_ = valid_options;
- options_[CatalogOptions::TOKEN_PROVIDER] = "dlf";
+ options_[CatalogOptions::TOKEN_PROVIDER] = "unsupported";
Status unsupported_provider = CreateRestCatalog().status();
ASSERT_TRUE(unsupported_provider.IsNotImplemented()) <<
unsupported_provider.ToString();
ASSERT_NOK_WITH_MSG(unsupported_provider, "unsupported token provider");
@@ -991,6 +991,12 @@ TEST(RestApiErrorTest, ErrorToStatus) {
ASSERT_NOK_WITH_MSG(RestApi::ErrorToStatus(response), "rest request failed
with code 429");
response.code = 418;
ASSERT_NOK_WITH_MSG(RestApi::ErrorToStatus(response), "rest request failed
with code 418");
+
+ response.code = 302;
+ Status signed_redirect = RestApi::ErrorToStatus(response,
/*follow_redirects=*/false);
+ ASSERT_NOK_WITH_MSG(signed_redirect, "redirect status 302");
+ ASSERT_NOK_WITH_MSG(signed_redirect, "not followed for signed requests");
+ ASSERT_EQ(302,
checked_pointer_cast<RestErrorDetail>(signed_redirect.detail())->GetCode());
}
TEST(RestApiErrorTest, MalformedSuccessBodyFails) {
diff --git a/src/paimon/rest/rest_http_client.cpp
b/src/paimon/rest/rest_http_client.cpp
index 52e6417e..10695818 100644
--- a/src/paimon/rest/rest_http_client.cpp
+++ b/src/paimon/rest/rest_http_client.cpp
@@ -249,7 +249,7 @@ std::string RestHttpClient::BuildQueryString(
Result<RestHttpClient::Response> RestHttpClient::ExecuteOnce(
const std::string& method, const std::string& url,
const std::map<std::string, std::string>& headers, const std::string& body,
- bool* transport_retriable) const {
+ bool follow_redirects, bool* transport_retriable) const {
CURL* curl = handle_pool_->Acquire();
if (curl == nullptr) {
return Status::IOError("failed to create curl handle");
@@ -271,10 +271,11 @@ Result<RestHttpClient::Response>
RestHttpClient::ExecuteOnce(
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response.body);
curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, WriteHeaderCallback);
curl_easy_setopt(curl, CURLOPT_HEADERDATA, &response.headers);
- // Follow redirects transparently, restricted to http(s) targets. Without
+ // Redirects can be disabled for auth headers whose signatures are bound
to the
+ // original request. Otherwise they are restricted to http(s) targets.
Without
// CURLOPT_POSTREDIR a 301/302 would replay a body-carrying request as a
bodyless
// GET. A 303 is left to become a GET, which is what it is defined to mean.
- curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
+ curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, follow_redirects ? 1L : 0L);
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 50L);
curl_easy_setopt(curl, CURLOPT_POSTREDIR,
static_cast<long>(CURL_REDIR_POST_301 | //
NOLINT(runtime/int)
@@ -399,7 +400,8 @@ std::optional<int64_t>
RestHttpClient::GetRetryDelayMs(int32_t execution_count,
Result<RestHttpClient::Response> RestHttpClient::Execute(
const std::string& method, const std::string& path,
const std::map<std::string, std::string>& query_params,
- const std::map<std::string, std::string>& headers, const std::string&
body) const {
+ const std::map<std::string, std::string>& headers, const std::string& body,
+ bool follow_redirects) const {
if (method != "GET" && method != "POST" && method != "DELETE") {
return Status::Invalid(fmt::format("unsupported http method: {}",
method));
}
@@ -423,7 +425,8 @@ Result<RestHttpClient::Response> RestHttpClient::Execute(
while (true) {
execution_count++;
bool transport_retriable = false;
- Result<Response> result = ExecuteOnce(method, url, headers, body,
&transport_retriable);
+ Result<Response> result =
+ ExecuteOnce(method, url, headers, body, follow_redirects,
&transport_retriable);
bool retriable;
if (result.ok()) {
retriable = IsRetriableCode(result.value().code);
diff --git a/src/paimon/rest/rest_http_client.h
b/src/paimon/rest/rest_http_client.h
index 07712795..412e9853 100644
--- a/src/paimon/rest/rest_http_client.h
+++ b/src/paimon/rest/rest_http_client.h
@@ -50,8 +50,8 @@ struct HttpStatus {
/// response header (delta-seconds or HTTP-date form). A backoff sleep is
bounded by
/// `retry_max_delay_ms` and the whole request by `retry_timeout_ms`; a
`Retry-After`
/// beyond the remaining budget stops retrying rather than shortening the
sleep.
-/// Redirects to http(s) targets are followed transparently, keeping the
method and
-/// body of POST/DELETE requests.
+/// Redirects to http(s) targets are followed by default, keeping the method
and body
+/// of POST/DELETE requests; callers can disable them for request-bound
signatures.
class RestHttpClient {
public:
struct Config {
@@ -96,11 +96,12 @@ class RestHttpClient {
/// final response, which may carry a non-2xx code, or an error status
when the
/// request could not be transported at all. Only transient transport
errors (an
/// established connection breaking mid-request or a truncated response
body) are
- /// retried; every other transport failure fails immediately.
+ /// retried; every other transport failure fails immediately.
`follow_redirects`
+ /// must be false when authentication headers are bound to the original
request.
Result<Response> Execute(const std::string& method, const std::string&
path,
const std::map<std::string, std::string>&
query_params,
const std::map<std::string, std::string>& headers,
- const std::string& body) const;
+ const std::string& body, bool follow_redirects =
true) const;
const std::string& GetBaseUri() const {
return base_uri_;
@@ -135,7 +136,8 @@ class RestHttpClient {
/// may be retried; see `Execute` for which kinds are not.
Result<Response> ExecuteOnce(const std::string& method, const std::string&
url,
const std::map<std::string, std::string>&
headers,
- const std::string& body, bool*
transport_retriable) const;
+ const std::string& body, bool
follow_redirects,
+ bool* transport_retriable) const;
std::optional<int64_t> GetRetryDelayMs(int32_t execution_count, const
Response* response,
int64_t remaining_budget_ms) const;
diff --git a/src/paimon/rest/rest_http_client_test.cpp
b/src/paimon/rest/rest_http_client_test.cpp
index 34d07fe6..82ea9e2a 100644
--- a/src/paimon/rest/rest_http_client_test.cpp
+++ b/src/paimon/rest/rest_http_client_test.cpp
@@ -489,6 +489,25 @@ TEST(RestHttpClientTest, RedirectIsFollowed) {
ASSERT_EQ(0, response.headers.count("location"));
}
+TEST(RestHttpClientTest, RedirectCanBeDisabledForSignedRequests) {
+ std::atomic<int32_t> request_count{0};
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<MockRestServer> server,
+ MockRestServer::Start([&](const
MockRestServer::Request& request) {
+ request_count++;
+ MockRestServer::Response response;
+ response.code = 302;
+ response.headers["Location"] = "/v1/config";
+ return response;
+ }));
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestHttpClient> client,
+ RestHttpClient::Create(server->GetBaseUri()));
+ ASSERT_OK_AND_ASSIGN(RestHttpClient::Response response,
+ client->Execute("GET", "/old", {},
{{"x-acs-security-token", "secret"}},
+ "", /*follow_redirects=*/false));
+ ASSERT_EQ(302, response.code);
+ ASSERT_EQ(1, request_count.load());
+}
+
TEST(RestHttpClientTest, PostRedirectKeepsMethodAndBody) {
std::mutex mutex;
MockRestServer::Request last_request;