This is an automated email from the ASF dual-hosted git repository.

JingsongLi 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 642d07dc feat(rest): support DLF openapi-v4 signing algorithm (#469)
642d07dc is described below

commit 642d07dc2ad4b7cc4432dc6d9782d5f53eaf6dda
Author: Nicholas Jiang <[email protected]>
AuthorDate: Mon Oct 26 21:42:50 2026 +0700

    feat(rest): support DLF openapi-v4 signing algorithm (#469)
---
 docs/source/user_guide/catalog.rst |   9 +-
 include/paimon/catalog_options.h   |   2 +-
 src/paimon/rest/dlf_auth.cpp       | 334 ++++++++++++++++++++++++++++++++++---
 src/paimon/rest/dlf_auth.h         |  51 +++++-
 src/paimon/rest/dlf_auth_test.cpp  | 271 +++++++++++++++++++++++++++++-
 5 files changed, 635 insertions(+), 32 deletions(-)

diff --git a/docs/source/user_guide/catalog.rst 
b/docs/source/user_guide/catalog.rst
index d881968c..e311b52b 100644
--- a/docs/source/user_guide/catalog.rst
+++ b/docs/source/user_guide/catalog.rst
@@ -67,8 +67,11 @@ registered on the REST server. The catalog is configured 
through the
   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``.
+  ``openapi-v4`` selects ACS4-HMAC-SHA256 for DlfNext OpenAPI endpoints. When a
+  request matches a registered DlfNext operation, it also names that 
operation's
+  API in the ``x-acs-action`` header; other requests are signed without it.
+  Without this option, an endpoint containing ``dlfnext`` selects ``openapi`` 
and
+  other endpoints select ``default``; ``openapi-v4`` is only used when 
configured.
 * ``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
@@ -103,6 +106,8 @@ are reloaded when less than one hour of validity remains.
        {"dlf.access-key-secret", "<access-key-secret>"},
        // Optional for temporary credentials:
        {"dlf.security-token", "<security-token>"},
+       // Optional for endpoints that require ACS4-HMAC-SHA256 signing:
+       {"dlf.signing-algorithm", "openapi-v4"},
    };
 
 On creation the catalog queries the server's ``/v1/config`` endpoint and merges
diff --git a/include/paimon/catalog_options.h b/include/paimon/catalog_options.h
index f781fa18..c1862ad9 100644
--- a/include/paimon/catalog_options.h
+++ b/include/paimon/catalog_options.h
@@ -62,7 +62,7 @@ struct PAIMON_EXPORT CatalogOptions {
     /// "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").
+    /// "dlf.signing-algorithm" - DLF signer ("default", "openapi" or 
"openapi-v4").
     static const char DLF_SIGNING_ALGORITHM[];
 
     /// "dlf.oss-endpoint" - OSS endpoint that overrides the "fs.oss.endpoint" 
of a data
diff --git a/src/paimon/rest/dlf_auth.cpp b/src/paimon/rest/dlf_auth.cpp
index 6a490627..ae5b6f0c 100644
--- a/src/paimon/rest/dlf_auth.cpp
+++ b/src/paimon/rest/dlf_auth.cpp
@@ -21,6 +21,7 @@
 
 #include <openssl/evp.h>
 
+#include <algorithm>
 #include <array>
 #include <climits>
 #include <ctime>
@@ -72,6 +73,13 @@ 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";
+constexpr const char kAcsDateHeader[] = "x-acs-date";
+constexpr const char kAcsContentSha256Header[] = "x-acs-content-sha256";
+constexpr const char kAcsActionHeader[] = "x-acs-action";
+constexpr const char kAcs4Algorithm[] = "ACS4-HMAC-SHA256";
+// SHA-256 of the empty string, the payload hash of a request without a body.
+constexpr const char kEmptyBodySha256[] =
+    "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
 
 Result<std::string> RequiredNonEmptyOption(const std::map<std::string, 
std::string>& options,
                                            const std::string& key) {
@@ -119,6 +127,15 @@ Result<std::string> 
FormatDlfTime(std::chrono::system_clock::time_point time) {
     return std::string(buffer.data());
 }
 
+Result<std::string> FormatAcsTime(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 OpenAPI V4 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",
@@ -226,6 +243,17 @@ std::string HexEncode(const Bytes& value) {
     return encoded;
 }
 
+// The signing key shared by the DLF4 and ACS4 schemes, derived from the 
secret through the
+// date, the region and the product.
+Result<Bytes> DeriveSigningKey(const std::string& access_key_secret, const 
std::string& date,
+                               const std::string& region) {
+    PAIMON_ASSIGN_OR_RAISE(Bytes date_key,
+                           Hmac(EVP_sha256(), ToBytes("aliyun_v4" + 
access_key_secret), date));
+    PAIMON_ASSIGN_OR_RAISE(Bytes region_key, Hmac(EVP_sha256(), date_key, 
region));
+    PAIMON_ASSIGN_OR_RAISE(Bytes product_key, Hmac(EVP_sha256(), region_key, 
"DlfNext"));
+    return Hmac(EVP_sha256(), product_key, "aliyun_v4_request");
+}
+
 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");
@@ -343,6 +371,85 @@ Result<std::string> 
GenerateNonce(std::chrono::system_clock::time_point now) {
     return fmt::format("{}{}{}", uuid, millis, thread_id.str());
 }
 
+struct Acs4CanonicalHeaders {
+    std::string canonical_headers;
+    std::string signed_headers;
+};
+
+// The headers the ACS4 scheme signs: "host", "content-type" and every 
"x-acs-*" header, named
+// in lower case with trimmed values and sorted by name.
+Acs4CanonicalHeaders Acs4CanonicalizeHeaders(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-") || lower_key == 
"host" ||
+            lower_key == "content-type") {
+            sorted[lower_key] = Trimmed(value);
+        }
+    }
+    Acs4CanonicalHeaders result;
+    for (const auto& [key, value] : sorted) {
+        result.canonical_headers += key + ":" + value + "\n";
+        if (!result.signed_headers.empty()) {
+            result.signed_headers += ";";
+        }
+        result.signed_headers += key;
+    }
+    return result;
+}
+
+// The query parameters sorted by name and RFC 3986 encoded. 
`RestAuthParameter` holds the
+// values url-encoded once already, so they are decoded first: encoding them 
again would sign a
+// form the gateway cannot rebuild from the wire.
+std::string Acs4CanonicalQueryString(const RestAuthParameter& parameter) {
+    std::string canonical;
+    bool first = true;
+    for (const auto& [key, value] : parameter.parameters) {
+        if (!first) {
+            canonical += "&";
+        }
+        canonical += UrlUtils::PercentEncode(key) + "=";
+        if (!value.empty()) {
+            canonical += 
UrlUtils::PercentEncode(UrlUtils::DecodeString(value));
+        }
+        first = false;
+    }
+    return canonical;
+}
+
+struct SplitOperation {
+    std::vector<std::string> template_segments;
+    std::string action;
+};
+
+// The registered operations grouped by method, with their path templates 
split once.
+const std::map<std::string, std::vector<SplitOperation>>& OperationsByMethod() 
{
+    static const std::map<std::string, std::vector<SplitOperation>> 
kOperationsByMethod = [] {
+        std::map<std::string, std::vector<SplitOperation>> 
operations_by_method;
+        for (const DlfOpenApiActions::Operation& operation : 
DlfOpenApiActions::Operations()) {
+            operations_by_method[operation.method].push_back(
+                {StringUtils::Split(operation.path_template, "/", 
/*ignore_empty=*/false),
+                 operation.action});
+        }
+        return operations_by_method;
+    }();
+    return kOperationsByMethod;
+}
+
+bool MatchesPathTemplate(const std::vector<std::string>& template_segments,
+                         const std::vector<std::string>& segments) {
+    if (segments.size() != template_segments.size()) {
+        return false;
+    }
+    for (size_t i = 0; i < template_segments.size(); ++i) {
+        bool variable = StringUtils::StartsWith(template_segments[i], "{");
+        if (variable ? segments[i].empty() : template_segments[i] != 
segments[i]) {
+            return false;
+        }
+    }
+    return true;
+}
+
 Result<std::unique_ptr<DlfRequestSigner>> CreateSigner(const std::string& 
algorithm,
                                                        const std::string& 
region) {
     if (algorithm == DlfDefaultSigner::kIdentifier) {
@@ -351,9 +458,13 @@ Result<std::unique_ptr<DlfRequestSigner>> 
CreateSigner(const std::string& algori
     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));
+    if (algorithm == DlfOpenApiV4Signer::kIdentifier) {
+        return std::make_unique<DlfOpenApiV4Signer>(region);
+    }
+    return Status::Invalid(
+        fmt::format("unsupported DLF signing algorithm '{}', supported values 
are 'default', "
+                    "'openapi' and 'openapi-v4'",
+                    algorithm));
 }
 
 }  // namespace
@@ -531,24 +642,21 @@ Result<DlfRequestSigner::Headers> 
DlfDefaultSigner::SignHeaders(
 
 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));
+                                                    Headers* sign_headers) 
const {
+    const Headers& headers = *sign_headers;
+    PAIMON_ASSIGN_OR_RAISE(std::string date_time, RequiredHeader(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);
+    std::string canonical_request = DefaultCanonicalRequest(parameter, 
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 signing_key,
+                           DeriveSigningKey(token.GetAccessKeySecret(), date, 
region_));
     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));
@@ -579,28 +687,212 @@ Result<DlfRequestSigner::Headers> 
DlfOpenApiSigner::SignHeaders(
 
 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));
+                                                    Headers* sign_headers) 
const {
+    const Headers& headers = *sign_headers;
+    PAIMON_ASSIGN_OR_RAISE(std::string accept, RequiredHeader(headers, 
kOpenApiAcceptHeader));
+    PAIMON_ASSIGN_OR_RAISE(std::string date, RequiredHeader(headers, 
kOpenApiDateHeader));
     std::string content_md5;
-    auto md5_iter = sign_headers.find(kContentMd5Header);
-    if (md5_iter != sign_headers.end()) {
+    auto md5_iter = headers.find(kContentMd5Header);
+    if (md5_iter != headers.end()) {
         content_md5 = md5_iter->second;
     }
     std::string content_type;
-    auto type_iter = sign_headers.find(kContentTypeHeader);
-    if (type_iter != sign_headers.end()) {
+    auto type_iter = headers.find(kContentTypeHeader);
+    if (type_iter != 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);
+        "\n" + OpenApiCanonicalizedHeaders(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);
 }
 
+DlfOpenApiV4Signer::DlfOpenApiV4Signer(const std::string& region) : 
region_(region) {}
+
+Result<DlfRequestSigner::Headers> DlfOpenApiV4Signer::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, FormatAcsTime(now));
+    PAIMON_ASSIGN_OR_RAISE(std::string nonce, GenerateNonce(now));
+    // "Host" and "Content-Type" keep the spelling `RestApi` and the other 
signers use, so that
+    // merging these headers replaces a header of the same name rather than 
sending a second
+    // copy of it; the signature names every header in lower case either way.
+    Headers headers = {{kAcsDateHeader, date},
+                       {kOpenApiHostHeader, host},
+                       {kAcsContentSha256Header, kEmptyBodySha256},
+                       {kAcsSignatureNonceHeader, nonce},
+                       {kAcsVersionHeader, "2026-01-18"}};
+    if (!body.empty()) {
+        PAIMON_ASSIGN_OR_RAISE(Bytes body_hash, Digest(EVP_sha256(), body));
+        headers[kAcsContentSha256Header] = HexEncode(body_hash);
+        headers[kContentTypeHeader] = kJsonMediaType;
+    }
+    if (security_token) {
+        headers[kAcsSecurityTokenHeader] = security_token.value();
+    }
+    return headers;
+}
+
+Result<std::string> DlfOpenApiV4Signer::Authorization(const RestAuthParameter& 
parameter,
+                                                      const DlfToken& token,
+                                                      const std::string& host,
+                                                      Headers* sign_headers) 
const {
+    PAIMON_ASSIGN_OR_RAISE(std::string date_time, 
RequiredHeader(*sign_headers, kAcsDateHeader));
+    if (date_time.size() < 10) {
+        return Status::Invalid("DLF signing date is invalid");
+    }
+    std::string date = date_time.substr(0, 10);
+    date.erase(std::remove(date.begin(), date.end(), '-'), date.end());
+
+    // POP gateways may route by the action, so it is signed and sent with the 
other headers.
+    std::optional<std::string> action =
+        DlfOpenApiActions::Resolve(parameter.method, parameter.resource_path);
+    if (action) {
+        (*sign_headers)[kAcsActionHeader] = action.value();
+    }
+
+    Acs4CanonicalHeaders canonical_headers = 
Acs4CanonicalizeHeaders(*sign_headers);
+    std::string canonical_uri = 
StringUtils::IsNullOrWhitespaceOnly(parameter.resource_path)
+                                    ? std::string("/")
+                                    : parameter.resource_path;
+    auto payload_iter = sign_headers->find(kAcsContentSha256Header);
+    std::string hashed_payload =
+        payload_iter == sign_headers->end() ? std::string(kEmptyBodySha256) : 
payload_iter->second;
+    std::string canonical_request = parameter.method + "\n" + canonical_uri + 
"\n" +
+                                    Acs4CanonicalQueryString(parameter) + "\n" 
+
+                                    canonical_headers.canonical_headers + "\n" 
+
+                                    canonical_headers.signed_headers + "\n" + 
hashed_payload;
+    PAIMON_ASSIGN_OR_RAISE(Bytes canonical_hash, Digest(EVP_sha256(), 
canonical_request));
+    std::string string_to_sign = std::string(kAcs4Algorithm) + "\n" + 
HexEncode(canonical_hash);
+
+    PAIMON_ASSIGN_OR_RAISE(Bytes signing_key,
+                           DeriveSigningKey(token.GetAccessKeySecret(), date, 
region_));
+    PAIMON_ASSIGN_OR_RAISE(Bytes signature, Hmac(EVP_sha256(), signing_key, 
string_to_sign));
+    return fmt::format(
+        "{} 
Credential={}/{}/{}/DlfNext/aliyun_v4_request,SignedHeaders={},Signature={}",
+        kAcs4Algorithm, token.GetAccessKeyId(), date, region_, 
canonical_headers.signed_headers,
+        HexEncode(signature));
+}
+
+const std::vector<DlfOpenApiActions::Operation>& 
DlfOpenApiActions::Operations() {
+    // Mirrors the Java `DLFOpenApiActions`, so both clients name every 
request the same way.
+    static const std::vector<Operation> kOperations = {
+        {"GET", "/v1/config", "GetConfig"},
+        {"GET", "/v1/{prefix}/databases", "ListDatabases"},
+        {"POST", "/v1/{prefix}/databases", "CreateDatabase"},
+        {"GET", "/v1/{prefix}/databases/{database}", "GetDatabase"},
+        {"POST", "/v1/{prefix}/databases/{database}", "AlterDatabase"},
+        {"DELETE", "/v1/{prefix}/databases/{database}", "DropDatabase"},
+        {"POST", "/v1/{prefix}/databases/{database}/register", 
"RegisterTable"},
+        {"GET", "/v1/{prefix}/databases/{database}/tables", "ListTables"},
+        {"POST", "/v1/{prefix}/databases/{database}/tables", "CreateTable"},
+        {"GET", "/v1/{prefix}/databases/{database}/table-details", 
"ListTableDetails"},
+        {"GET", "/v1/{prefix}/tables", "ListTablesGlobally"},
+        {"GET", "/v1/{prefix}/databases/{database}/tables/{table}", 
"GetTable"},
+        {"POST", "/v1/{prefix}/databases/{database}/tables/{table}", 
"AlterTable"},
+        {"DELETE", "/v1/{prefix}/databases/{database}/tables/{table}", 
"DropTable"},
+        {"GET", "/v1/{prefix}/tables/id/{tableId}", "GetTableById"},
+        {"POST", "/v1/{prefix}/tables/rename", "RenameTable"},
+        {"POST", "/v1/{prefix}/databases/{database}/tables/{table}/auth", 
"AuthTableQuery"},
+        {"POST", "/v1/{prefix}/databases/{database}/tables/{table}/commit", 
"CommitTable"},
+        {"POST", 
"/v1/{prefix}/databases/{database}/tables/{table}/commit-lock",
+         "AcquireCommitLock"},
+        {"POST", 
"/v1/{prefix}/databases/{database}/tables/{table}/commit-lock/renew",
+         "RenewCommitLock"},
+        {"POST", "/v1/{prefix}/databases/{database}/tables/{table}/replace", 
"ReplaceTable"},
+        {"GET", "/v1/{prefix}/databases/{database}/tables/{table}/token", 
"GetTableToken"},
+        {"POST", "/v1/{prefix}/databases/{database}/tables/{table}/rollback", 
"RollbackToSnapshot"},
+        {"POST", 
"/v1/{prefix}/databases/{database}/tables/{table}/rollback-schema",
+         "RollbackSchema"},
+        {"GET", "/v1/{prefix}/databases/{database}/tables/{table}/snapshot", 
"GetTableSnapshot"},
+        {"GET", "/v1/{prefix}/databases/{database}/tables/{table}/snapshots", 
"ListSnapshots"},
+        {"GET", 
"/v1/{prefix}/databases/{database}/tables/{table}/snapshots/{version}",
+         "GetVersionSnapshot"},
+        {"GET", "/v1/{prefix}/databases/{database}/tables/{table}/schemas", 
"ListSchemas"},
+        {"GET", 
"/v1/{prefix}/databases/{database}/tables/{table}/schemas/{version}", 
"GetSchema"},
+        {"GET", "/v1/{prefix}/databases/{database}/tables/{table}/partitions", 
"ListPartitions"},
+        {"POST", 
"/v1/{prefix}/databases/{database}/tables/{table}/partitions", 
"CreatePartitions"},
+        {"POST", 
"/v1/{prefix}/databases/{database}/tables/{table}/partitions/drop",
+         "DropPartitions"},
+        {"POST", 
"/v1/{prefix}/databases/{database}/tables/{table}/partitions/mark",
+         "MarkDonePartitions"},
+        {"POST", 
"/v1/{prefix}/databases/{database}/tables/{table}/partitions/list-by-names",
+         "ListPartitionsByNames"},
+        {"POST", 
"/v1/{prefix}/databases/{database}/tables/{table}/partitions/list-by-filter",
+         "ListPartitionsByFilter"},
+        {"GET", "/v1/{prefix}/databases/{database}/tables/{table}/branches", 
"ListBranches"},
+        {"POST", "/v1/{prefix}/databases/{database}/tables/{table}/branches", 
"CreateBranch"},
+        {"DELETE", 
"/v1/{prefix}/databases/{database}/tables/{table}/branches/{branch}",
+         "DropBranch"},
+        {"POST", 
"/v1/{prefix}/databases/{database}/tables/{table}/branches/{branch}/forward",
+         "FastForwardBranch"},
+        {"GET", "/v1/{prefix}/databases/{database}/tables/{table}/tags", 
"ListTags"},
+        {"POST", "/v1/{prefix}/databases/{database}/tables/{table}/tags", 
"CreateTag"},
+        {"GET", "/v1/{prefix}/databases/{database}/tables/{table}/tags/{tag}", 
"GetTag"},
+        {"DELETE", 
"/v1/{prefix}/databases/{database}/tables/{table}/tags/{tag}", "DropTag"},
+        {"GET", "/v1/{prefix}/databases/{database}/tables/{table}/consumers", 
"ListConsumers"},
+        {"POST", 
"/v1/{prefix}/databases/{database}/tables/{table}/consumers/reset",
+         "ResetConsumer"},
+        {"GET", "/v1/{prefix}/databases/{database}/tables/{table}/policies", 
"ListPolicies"},
+        {"POST", "/v1/{prefix}/databases/{database}/tables/{table}/policies", 
"CreatePolicy"},
+        {"POST", 
"/v1/{prefix}/databases/{database}/tables/{table}/policies/drop", "DropPolicy"},
+        {"GET", "/v1/{prefix}/databases/{database}/views", "ListViews"},
+        {"POST", "/v1/{prefix}/databases/{database}/views", "CreateView"},
+        {"GET", "/v1/{prefix}/databases/{database}/view-details", 
"ListViewDetails"},
+        {"GET", "/v1/{prefix}/views", "ListViewsGlobally"},
+        {"GET", "/v1/{prefix}/databases/{database}/views/{view}", "GetView"},
+        {"POST", "/v1/{prefix}/databases/{database}/views/{view}", 
"AlterView"},
+        {"DELETE", "/v1/{prefix}/databases/{database}/views/{view}", 
"DropView"},
+        {"POST", "/v1/{prefix}/views/rename", "RenameView"},
+        {"GET", "/v1/{prefix}/databases/{database}/semantic-views", 
"ListSemanticViews"},
+        {"GET", 
"/v1/{prefix}/databases/{database}/semantic-views/{semanticView}",
+         "GetSemanticView"},
+        {"POST", 
"/v1/{prefix}/databases/{database}/semantic-views/{semanticView}",
+         "UpsertSemanticView"},
+        {"DELETE", 
"/v1/{prefix}/databases/{database}/semantic-views/{semanticView}",
+         "DeleteSemanticView"},
+        {"GET", "/v1/{prefix}/databases/{database}/functions", 
"ListFunctions"},
+        {"POST", "/v1/{prefix}/databases/{database}/functions", 
"CreateFunction"},
+        {"GET", "/v1/{prefix}/databases/{database}/function-details", 
"ListFunctionDetails"},
+        {"GET", "/v1/{prefix}/functions", "ListFunctionsGlobally"},
+        {"GET", "/v1/{prefix}/databases/{database}/functions/{function}", 
"GetFunction"},
+        {"POST", "/v1/{prefix}/databases/{database}/functions/{function}", 
"AlterFunction"},
+        {"DELETE", "/v1/{prefix}/databases/{database}/functions/{function}", 
"DropFunction"},
+        {"GET", "/v1/{prefix}/permissions", "ListPermissionAssignments"},
+        {"POST", "/v1/{prefix}/permissions/grant", 
"GrantPermissionAssignment"},
+        {"POST", "/v1/{prefix}/permissions/revoke", 
"RevokePermissionAssignment"},
+        {"GET", "/v1/{prefix}/labels", "ListLabels"},
+        {"POST", "/v1/{prefix}/labels/get", "GetLabel"},
+        {"POST", "/v1/{prefix}/labels/upsert", "UpsertLabel"},
+        {"POST", "/v1/{prefix}/labels/delete", "DeleteLabel"},
+    };
+    return kOperations;
+}
+
+std::optional<std::string> DlfOpenApiActions::Resolve(const std::string& 
method,
+                                                      const std::string& 
resource_path) {
+    const std::map<std::string, std::vector<SplitOperation>>& 
operations_by_method =
+        OperationsByMethod();
+    auto candidates = 
operations_by_method.find(StringUtils::ToUpperCase(method));
+    if (candidates == operations_by_method.end()) {
+        return std::nullopt;
+    }
+    std::vector<std::string> segments =
+        StringUtils::Split(resource_path, "/", /*ignore_empty=*/false);
+    for (const SplitOperation& operation : candidates->second) {
+        if (MatchesPathTemplate(operation.template_segments, segments)) {
+            return operation.action;
+        }
+    }
+    return std::nullopt;
+}
+
 DlfAuthProvider::DlfAuthProvider(std::unique_ptr<DlfTokenLoader> token_loader,
                                  const std::optional<DlfToken>& token, const 
std::string& host,
                                  std::unique_ptr<DlfRequestSigner> signer, 
Clock clock)
@@ -744,7 +1036,7 @@ Result<std::map<std::string, std::string>> 
DlfAuthProvider::MergeAuthHeader(
         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));
+                           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;
diff --git a/src/paimon/rest/dlf_auth.h b/src/paimon/rest/dlf_auth.h
index c10b655b..2f8238f0 100644
--- a/src/paimon/rest/dlf_auth.h
+++ b/src/paimon/rest/dlf_auth.h
@@ -124,9 +124,11 @@ class DlfRequestSigner {
                                         const std::optional<std::string>& 
security_token,
                                         const std::string& host) const = 0;
 
+    /// Signs the request. `sign_headers` holds the headers `SignHeaders` 
returned and must not
+    /// be null; a signer may add headers to it, which it signs and the 
request must send too.
     virtual Result<std::string> Authorization(const RestAuthParameter& 
parameter,
                                               const DlfToken& token, const 
std::string& host,
-                                              const Headers& sign_headers) 
const = 0;
+                                              Headers* sign_headers) const = 0;
 };
 
 /// DLF4-HMAC-SHA256 signer used by the default DLF VPC endpoint.
@@ -142,7 +144,7 @@ class DlfDefaultSigner : public DlfRequestSigner {
 
     Result<std::string> Authorization(const RestAuthParameter& parameter, 
const DlfToken& token,
                                       const std::string& host,
-                                      const Headers& sign_headers) const 
override;
+                                      Headers* sign_headers) const override;
 
  private:
     std::string region_;
@@ -159,7 +161,50 @@ class DlfOpenApiSigner : public DlfRequestSigner {
 
     Result<std::string> Authorization(const RestAuthParameter& parameter, 
const DlfToken& token,
                                       const std::string& host,
-                                      const Headers& sign_headers) const 
override;
+                                      Headers* sign_headers) const override;
+};
+
+/// ACS4-HMAC-SHA256 signer for DlfNext OpenAPI endpoints, selected only when 
configured.
+/// Unlike the other signers, it adds an "x-acs-action" header to a request 
that matches a
+/// registered operation, naming that operation's API, and signs it with the 
other headers.
+class DlfOpenApiV4Signer : public DlfRequestSigner {
+ public:
+    static constexpr const char* kIdentifier = "openapi-v4";
+
+    explicit DlfOpenApiV4Signer(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,
+                                      Headers* sign_headers) const override;
+
+ private:
+    std::string region_;
+};
+
+/// Maps a REST request to the POP action it is registered under in DlfNext 
2026-01-18.
+class DlfOpenApiActions {
+ public:
+    /// One registered operation. A "{name}" segment of `path_template` 
matches any one
+    /// non-empty path segment.
+    struct Operation {
+        const char* method;
+        const char* path_template;
+        const char* action;
+    };
+
+    DlfOpenApiActions() = delete;
+
+    /// Every registered operation, in the order requests are matched against 
them.
+    static const std::vector<Operation>& Operations();
+
+    /// Returns the action of the request, or `std::nullopt` when no operation 
is registered
+    /// for it. The method is matched ignoring case, the path segment by 
segment.
+    static std::optional<std::string> Resolve(const std::string& method,
+                                              const std::string& 
resource_path);
 };
 
 /// Generates DLF authentication headers and refreshes expiring credentials.
diff --git a/src/paimon/rest/dlf_auth_test.cpp 
b/src/paimon/rest/dlf_auth_test.cpp
index 5e82ff50..a6e3a388 100644
--- a/src/paimon/rest/dlf_auth_test.cpp
+++ b/src/paimon/rest/dlf_auth_test.cpp
@@ -24,6 +24,7 @@
 #include <cstdint>
 #include <ctime>
 #include <fstream>
+#include <map>
 #include <memory>
 #include <mutex>
 #include <optional>
@@ -36,7 +37,9 @@
 
 #include "gtest/gtest.h"
 #include "paimon/catalog_options.h"
+#include "paimon/rest/resource_paths.h"
 #include "paimon/testing/utils/testharness.h"
+#include "paimon/testing/utils/timezone_guard.h"
 
 namespace paimon::test {
 
@@ -48,6 +51,14 @@ std::chrono::system_clock::time_point FixedTime() {
     return std::chrono::system_clock::from_time_t(1744775086);
 }
 
+constexpr const char kEmptyBodySha256[] =
+    "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
+
+void AssertAction(const std::optional<std::string>& expected, const 
std::string& method,
+                  const std::string& path) {
+    ASSERT_EQ(expected, DlfOpenApiActions::Resolve(method, path)) << method << 
" " << path;
+}
+
 class SequenceTokenLoader : public DlfTokenLoader {
  public:
     explicit SequenceTokenLoader(
@@ -159,7 +170,7 @@ TEST(DlfDefaultSignerTest, SignsJavaCompatibleRequest) {
     ASSERT_EQ("securityToken", headers.at("x-dlf-security-token"));
 
     ASSERT_OK_AND_ASSIGN(std::string authorization,
-                         signer.Authorization(parameter, token, "unused", 
headers));
+                         signer.Authorization(parameter, token, "unused", 
&headers));
     ASSERT_EQ(
         "DLF4-HMAC-SHA256 Credential=YourAccessKeyId/20250416/cn-beijing/"
         "DlfNext/aliyun_v4_request,Signature="
@@ -180,7 +191,7 @@ TEST(DlfDefaultSignerTest, MatchesJavaGoldenAuthorization) {
     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));
+                         signer.Authorization(parameter, token, "host", 
&headers));
     ASSERT_EQ(
         "DLF4-HMAC-SHA256 Credential=access-key-id/20231203/cn-hangzhou/"
         "DlfNext/aliyun_v4_request,Signature="
@@ -220,7 +231,7 @@ TEST(DlfOpenApiSignerTest, SignsJavaCompatibleRequest) {
     ASSERT_EQ("securityToken", headers.at("x-acs-security-token"));
 
     ASSERT_OK_AND_ASSIGN(std::string authorization,
-                         signer.Authorization(parameter, token, host, 
headers));
+                         signer.Authorization(parameter, token, host, 
&headers));
     ASSERT_EQ("acs YourAccessKeyId:wX4CDPSCtfgYkxdK9tJIO3ez5VI=", 
authorization);
 }
 
@@ -234,7 +245,7 @@ TEST(DlfOpenApiSignerTest, 
DecodesPathAndQueryValuesBeforeSigning) {
     headers["x-acs-signature-nonce"] = "fixed-nonce";
 
     ASSERT_OK_AND_ASSIGN(std::string authorization,
-                         signer.Authorization(parameter, token, "host", 
headers));
+                         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"));
@@ -487,7 +498,7 @@ TEST(DlfDefaultSignerTest, SignsSpecialCharQueryLikeJava) {
     ASSERT_EQ("20231203T121212Z", headers.at("x-dlf-date"));
     ASSERT_EQ("F2vHiexkcYvt4XOY5o4tmQ==", headers.at("Content-MD5"));
     ASSERT_OK_AND_ASSIGN(std::string authorization,
-                         signer.Authorization(parameter, token, "host", 
headers));
+                         signer.Authorization(parameter, token, "host", 
&headers));
     ASSERT_EQ(
         "DLF4-HMAC-SHA256 Credential=access-key-id/20231203/cn-hangzhou/"
         "DlfNext/aliyun_v4_request,Signature="
@@ -579,6 +590,7 @@ TEST(DlfOpenApiSignerTest, 
ConcurrentNonceGenerationStaysUnique) {
 TEST(DlfSignerTest, ExposesJavaCompatibleIdentifiers) {
     ASSERT_EQ(std::string("default"), 
std::string(DlfDefaultSigner::kIdentifier));
     ASSERT_EQ(std::string("openapi"), 
std::string(DlfOpenApiSigner::kIdentifier));
+    ASSERT_EQ(std::string("openapi-v4"), 
std::string(DlfOpenApiV4Signer::kIdentifier));
 }
 
 TEST(DlfAuthProviderTest, ParsesSigningAlgorithmFromUriBoundaries) {
@@ -618,4 +630,253 @@ TEST(DlfAuthProviderTest, ParsesRegionFromUriVariants) {
                         "could not determine DLF region");
 }
 
+TEST(DlfOpenApiV4SignerTest, SignsHeadersWithBody) {
+    DlfOpenApiV4Signer signer("cn-beijing");
+    const std::string body = 
R"({"CategoryName":"test","CategoryType":"UNSTRUCTURED"})";
+    const std::string host = "dlfnext.cn-beijing.aliyuncs.com";
+    ASSERT_OK_AND_ASSIGN(DlfRequestSigner::Headers headers,
+                         signer.SignHeaders(body, FixedTime(), std::nullopt, 
host));
+    ASSERT_EQ("2025-04-16T03:44:46Z", headers.at("x-acs-date"));
+    ASSERT_EQ(host, headers.at("Host"));
+    
ASSERT_EQ("d8171130c3edc0f7d4e2827c5dab801a603c9a376b214a9ab6d5d12665c8c5df",
+              headers.at("x-acs-content-sha256"));
+    ASSERT_EQ("application/json", headers.at("Content-Type"));
+    ASSERT_FALSE(headers.at("x-acs-signature-nonce").empty());
+    ASSERT_EQ("2026-01-18", headers.at("x-acs-version"));
+    ASSERT_EQ(6, headers.size());
+
+    ASSERT_NOK_WITH_MSG(signer.SignHeaders(body, FixedTime(), std::nullopt, 
"").status(),
+                        "host is empty");
+}
+
+TEST(DlfOpenApiV4SignerTest, OmitsBodyAndSecurityTokenHeadersWhenAbsent) {
+    DlfOpenApiV4Signer signer("cn-beijing");
+    ASSERT_OK_AND_ASSIGN(DlfRequestSigner::Headers headers,
+                         signer.SignHeaders("", FixedTime(), std::nullopt, 
"host"));
+    ASSERT_EQ(kEmptyBodySha256, headers.at("x-acs-content-sha256"));
+    ASSERT_EQ(0, headers.count("Content-Type"));
+    ASSERT_EQ(0, headers.count("x-acs-security-token"));
+    ASSERT_EQ(5, headers.size());
+}
+
+TEST(DlfOpenApiV4SignerTest, SignsUtcDateInNonUtcTimezone) {
+    TimezoneGuard timezone_guard("Asia/Shanghai");
+    DlfOpenApiV4Signer signer("cn-beijing");
+    DlfToken token("TestAKId", "TestAKSecret", std::nullopt, std::nullopt);
+    RestAuthParameter parameter = RestAuthParameter::Create("GET", 
"/test/path", {}, "");
+    ASSERT_OK_AND_ASSIGN(DlfRequestSigner::Headers headers,
+                         signer.SignHeaders("", 
std::chrono::system_clock::from_time_t(1744747200),
+                                            std::nullopt, "host"));
+    ASSERT_EQ("2025-04-15T20:00:00Z", headers.at("x-acs-date"));
+
+    ASSERT_OK_AND_ASSIGN(std::string authorization,
+                         signer.Authorization(parameter, token, "host", 
&headers));
+    ASSERT_EQ(0, authorization.find("ACS4-HMAC-SHA256 
Credential=TestAKId/20250415/cn-beijing/"));
+}
+
+TEST(DlfOpenApiV4SignerTest, MatchesJavaGoldenSignatureForEncodedQueryValues) {
+    DlfOpenApiV4Signer signer("cn-hangzhou");
+    DlfToken token("TestAKId", "TestAKSecret", std::nullopt, std::nullopt);
+    const std::string host = "dlfnext.cn-hangzhou.aliyuncs.com";
+    RestAuthParameter parameter =
+        RestAuthParameter::Create("GET", "/v1/prefix/functions",
+                                  {{"pageToken", "a b/c"}, 
{"functionNamePattern", "func%"}}, "");
+    ASSERT_OK_AND_ASSIGN(DlfRequestSigner::Headers headers,
+                         signer.SignHeaders("", FixedTime(), std::nullopt, 
host));
+    headers["x-acs-signature-nonce"] = "fixed-nonce-for-test";
+
+    ASSERT_OK_AND_ASSIGN(std::string authorization,
+                         signer.Authorization(parameter, token, host, 
&headers));
+    ASSERT_EQ(
+        "ACS4-HMAC-SHA256 
Credential=TestAKId/20250416/cn-hangzhou/DlfNext/aliyun_v4_request,"
+        "SignedHeaders=host;x-acs-action;x-acs-content-sha256;x-acs-date;"
+        "x-acs-signature-nonce;x-acs-version,"
+        
"Signature=2d5fdb5201901a5cdceee97318366666a88504eeae0b1ce534dbf86889cc00fb",
+        authorization);
+    ASSERT_EQ("ListFunctionsGlobally", headers.at("x-acs-action"));
+}
+
+TEST(DlfOpenApiV4SignerTest, MatchesJavaGoldenSignatureWithAction) {
+    DlfOpenApiV4Signer signer("cn-hangzhou");
+    DlfToken token("TestAKId", "TestAKSecret", std::nullopt, std::nullopt);
+    const std::string host = "dlfnext.cn-hangzhou.aliyuncs.com";
+    const std::string body = 
R"({"identifier":{"database":"db","object":"t"}})";
+    RestAuthParameter parameter =
+        RestAuthParameter::Create("POST", 
"/v1/clg-paimon-1/databases/db/tables", {}, body);
+    ASSERT_OK_AND_ASSIGN(DlfRequestSigner::Headers headers,
+                         signer.SignHeaders(body, FixedTime(), std::nullopt, 
host));
+    headers["x-acs-signature-nonce"] = "fixed-nonce-for-test";
+
+    ASSERT_OK_AND_ASSIGN(std::string authorization,
+                         signer.Authorization(parameter, token, host, 
&headers));
+    ASSERT_EQ("CreateTable", headers.at("x-acs-action"));
+    ASSERT_EQ(
+        "ACS4-HMAC-SHA256 
Credential=TestAKId/20250416/cn-hangzhou/DlfNext/aliyun_v4_request,"
+        "SignedHeaders=content-type;host;x-acs-action;x-acs-content-sha256;"
+        "x-acs-date;x-acs-signature-nonce;x-acs-version,"
+        
"Signature=420206b5263536e6bc271a7a32582b4a820e23c8c58ae692b708c69f9d19e51d",
+        authorization);
+}
+
+TEST(DlfOpenApiV4SignerTest, SignatureDependsOnRegion) {
+    DlfToken token("TestAKId", "TestAKSecret", std::nullopt, std::nullopt);
+    RestAuthParameter parameter = RestAuthParameter::Create("GET", 
"/test/path", {}, "");
+    DlfOpenApiV4Signer beijing("cn-beijing");
+    ASSERT_OK_AND_ASSIGN(DlfRequestSigner::Headers headers,
+                         beijing.SignHeaders("", FixedTime(), std::nullopt, 
"host"));
+    headers["x-acs-signature-nonce"] = "fixed-nonce-for-test";
+    ASSERT_OK_AND_ASSIGN(std::string beijing_authorization,
+                         beijing.Authorization(parameter, token, "host", 
&headers));
+
+    DlfOpenApiV4Signer hangzhou("cn-hangzhou");
+    ASSERT_OK_AND_ASSIGN(std::string hangzhou_authorization,
+                         hangzhou.Authorization(parameter, token, "host", 
&headers));
+    size_t beijing_signature_start = beijing_authorization.find(",Signature=");
+    size_t hangzhou_signature_start = 
hangzhou_authorization.find(",Signature=");
+    ASSERT_NE(std::string::npos, beijing_signature_start);
+    ASSERT_NE(std::string::npos, hangzhou_signature_start);
+    ASSERT_NE(beijing_authorization.substr(beijing_signature_start),
+              hangzhou_authorization.substr(hangzhou_signature_start));
+    ASSERT_NE(std::string::npos, 
beijing_authorization.find("/cn-beijing/DlfNext/"));
+    ASSERT_NE(std::string::npos, 
hangzhou_authorization.find("/cn-hangzhou/DlfNext/"));
+}
+
+TEST(DlfOpenApiV4SignerTest, SignsHeaderNamesInLowerCaseAndValuesTrimmed) {
+    DlfOpenApiV4Signer signer("cn-beijing");
+    DlfToken token("TestAKId", "TestAKSecret", std::nullopt, std::nullopt);
+    RestAuthParameter parameter = RestAuthParameter::Create("GET", 
"/test/path", {}, "");
+    ASSERT_OK_AND_ASSIGN(DlfRequestSigner::Headers headers,
+                         signer.SignHeaders("", FixedTime(), std::nullopt, 
"host"));
+    headers["x-acs-signature-nonce"] = "fixed-nonce-for-test";
+    DlfRequestSigner::Headers renamed_headers = headers;
+    renamed_headers.erase("x-acs-signature-nonce");
+    renamed_headers["X-ACS-SIGNATURE-NONCE"] = " fixed-nonce-for-test ";
+
+    ASSERT_OK_AND_ASSIGN(std::string expected,
+                         signer.Authorization(parameter, token, "host", 
&headers));
+    ASSERT_OK_AND_ASSIGN(std::string actual,
+                         signer.Authorization(parameter, token, "host", 
&renamed_headers));
+    ASSERT_EQ(expected, actual);
+}
+
+TEST(DlfOpenApiV4SignerTest, SignsUnregisteredPathWithoutAction) {
+    DlfOpenApiV4Signer signer("cn-beijing");
+    DlfToken token("TestAKId", "TestAKSecret", std::nullopt, std::nullopt);
+    RestAuthParameter parameter =
+        RestAuthParameter::Create("GET", 
"/v1/clg-paimon-1/databases/db/tables/t/unknown", {}, "");
+    ASSERT_OK_AND_ASSIGN(DlfRequestSigner::Headers headers,
+                         signer.SignHeaders("", FixedTime(), std::nullopt, 
"host"));
+
+    ASSERT_OK_AND_ASSIGN(std::string authorization,
+                         signer.Authorization(parameter, token, "host", 
&headers));
+    ASSERT_EQ(0, headers.count("x-acs-action"));
+    ASSERT_EQ(std::string::npos, authorization.find("x-acs-action"));
+}
+
+TEST(DlfOpenApiV4SignerTest, RequiresSigningDate) {
+    DlfOpenApiV4Signer signer("cn-beijing");
+    DlfToken token("TestAKId", "TestAKSecret", std::nullopt, std::nullopt);
+    RestAuthParameter parameter = RestAuthParameter::Create("GET", 
"/test/path", {}, "");
+    ASSERT_OK_AND_ASSIGN(DlfRequestSigner::Headers headers,
+                         signer.SignHeaders("", FixedTime(), std::nullopt, 
"host"));
+    headers["x-acs-date"] = "2025";
+    ASSERT_NOK_WITH_MSG(signer.Authorization(parameter, token, "host", 
&headers).status(),
+                        "signing date is invalid");
+    headers.erase("x-acs-date");
+    ASSERT_NOK_WITH_MSG(signer.Authorization(parameter, token, "host", 
&headers).status(),
+                        "x-acs-date");
+}
+
+TEST(DlfAuthProviderTest, SignsWithOpenApiV4OnlyWhenConfigured) {
+    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_EQ("openapi",
+              
DlfAuthProvider::ParseSigningAlgorithmFromUri(options.at(CatalogOptions::URI)));
+    const std::string body = R"({"name":"db"})";
+    RestAuthParameter parameter =
+        RestAuthParameter::Create("POST", "/v1/clg-paimon-1/databases", {}, 
body);
+    const StringMap base_headers = {{"Content-Type", "application/json"}};
+    for (const char* algorithm : {DlfDefaultSigner::kIdentifier, 
DlfOpenApiSigner::kIdentifier}) {
+        options[CatalogOptions::DLF_SIGNING_ALGORITHM] = algorithm;
+        ASSERT_OK_AND_ASSIGN(std::unique_ptr<AuthProvider> provider, 
AuthProvider::Create(options));
+        ASSERT_OK_AND_ASSIGN(StringMap headers, 
provider->MergeAuthHeader(base_headers, parameter));
+        ASSERT_EQ(0, headers.count("x-acs-action")) << algorithm;
+    }
+
+    options[CatalogOptions::DLF_SIGNING_ALGORITHM] = 
DlfOpenApiV4Signer::kIdentifier;
+    ASSERT_OK_AND_ASSIGN(std::unique_ptr<AuthProvider> provider, 
AuthProvider::Create(options));
+    ASSERT_OK_AND_ASSIGN(StringMap headers, 
provider->MergeAuthHeader(base_headers, parameter));
+    const std::string& authorization = headers.at("Authorization");
+    ASSERT_EQ(0, authorization.find("ACS4-HMAC-SHA256 Credential=ak/"));
+    ASSERT_NE(std::string::npos,
+              
authorization.find("/cn-hangzhou/DlfNext/aliyun_v4_request,SignedHeaders="
+                                 
"content-type;host;x-acs-action;x-acs-content-sha256;x-acs-date;"
+                                 
"x-acs-security-token;x-acs-signature-nonce;x-acs-version,"));
+    ASSERT_EQ("CreateDatabase", headers.at("x-acs-action"));
+    ASSERT_EQ("sts", headers.at("x-acs-security-token"));
+    ASSERT_EQ("application/json", headers.at("Content-Type"));
+    ASSERT_EQ(0, headers.count("content-type"));
+    ASSERT_EQ("dlfnext.cn-hangzhou.aliyuncs.com", headers.at("Host"));
+    ASSERT_EQ(0, headers.count("host"));
+}
+
+TEST(DlfOpenApiActionsTest, EveryOperationResolvesToItself) {
+    static const std::regex kVariable("\\{[^}]+\\}");
+    std::set<std::string> actions;
+    for (const DlfOpenApiActions::Operation& operation : 
DlfOpenApiActions::Operations()) {
+        AssertAction(operation.action, operation.method,
+                     std::regex_replace(operation.path_template, kVariable, 
"x1"));
+        actions.insert(operation.action);
+    }
+    ASSERT_EQ(DlfOpenApiActions::Operations().size(), actions.size());
+    ASSERT_EQ(74, actions.size());
+}
+
+TEST(DlfOpenApiActionsTest, ClientPathsResolveToRegisteredActions) {
+    ResourcePaths paths("clg-paimon-1");
+    AssertAction("GetConfig", "GET", ResourcePaths::Config());
+    AssertAction("ListDatabases", "GET", paths.Databases());
+    AssertAction("CreateDatabase", "POST", paths.Databases());
+    AssertAction("GetDatabase", "GET", paths.Database("db"));
+    AssertAction("DropDatabase", "DELETE", paths.Database("db"));
+    AssertAction("ListTables", "GET", paths.Tables("db"));
+    AssertAction("CreateTable", "POST", paths.Tables("db"));
+    AssertAction("GetTable", "GET", paths.Table("db", "t"));
+    AssertAction("DropTable", "DELETE", paths.Table("db", "t"));
+    AssertAction("RenameTable", "POST", paths.RenameTable());
+    AssertAction("ListSnapshots", "GET", paths.Snapshots("db", "t"));
+    AssertAction("CommitTable", "POST", paths.CommitTable("db", "t"));
+    AssertAction("GetTableSnapshot", "GET", paths.TableSnapshot("db", "t"));
+    AssertAction("GetTableToken", "GET", paths.TableToken("db", "t"));
+}
+
+TEST(DlfOpenApiActionsTest, MatchesWholeSegmentsAndMethodIgnoringCase) {
+    ResourcePaths paths("clg-paimon-1");
+    AssertAction("GetConfig", "get", ResourcePaths::Config());
+    AssertAction("GetDatabase", "GET", paths.Database("tables"));
+    AssertAction("GetTable", "GET", paths.Table("db", "token"));
+    AssertAction("GetTableToken", "GET", paths.TableToken("config", 
"t$snapshots"));
+    AssertAction("GetTable", "GET", paths.Table("a/b", "c d"));
+    AssertAction("ListDatabases", "GET", ResourcePaths("rename").Databases());
+    AssertAction("DropBranch", "DELETE", 
"/v1/clg-paimon-1/databases/db/tables/t/branches/forward");
+    AssertAction("GetTag", "GET", 
"/v1/clg-paimon-1/databases/db/tables/t/tags/tags");
+}
+
+TEST(DlfOpenApiActionsTest, UnregisteredRequestsHaveNoAction) {
+    ResourcePaths paths("clg-paimon-1");
+    AssertAction(std::nullopt, "GET", paths.RenameTable());
+    AssertAction(std::nullopt, "DELETE", "/v1/clg-paimon-1/tables/id/tbl-1");
+    AssertAction(std::nullopt, "GET", paths.CommitTable("db", "t"));
+    AssertAction(std::nullopt, "PUT", paths.Table("db", "t"));
+    AssertAction(std::nullopt, "GET", ResourcePaths::Config() + "/");
+    AssertAction(std::nullopt, "GET", "/v1//databases");
+    AssertAction(std::nullopt, "GET", ResourcePaths("").Databases());
+    AssertAction(std::nullopt, "", ResourcePaths::Config());
+    AssertAction(std::nullopt, "GET", "");
+}
+
 }  // namespace paimon::test

Reply via email to