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

XiaoHongbo-Hope pushed a commit to branch release-2.1
in repository https://gitbox.apache.org/repos/asf/paimon.git

commit f97545a0a6fba84f36f4b4f53a9d7d414a272023
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Fri Sep 18 13:01:21 2026 +0800

    [api] Sign x-acs-action in DLFOpenApiV4Signer (#9937)
---
 docs/docs/concepts/rest/dlf.md                     |   6 +-
 .../apache/paimon/rest/auth/DLFOpenApiActions.java | 184 +++++++++++++++++++++
 .../paimon/rest/auth/DLFOpenApiV4Signer.java       |  13 ++
 .../paimon/rest/auth/DLFOpenApiActionsTest.java    | 141 ++++++++++++++++
 .../paimon/rest/auth/DLFOpenApiV4SignerTest.java   |  92 +++++++++++
 .../pypaimon/api/auth/dlf_openapi_actions.py       | 103 ++++++++++++
 paimon-python/pypaimon/api/auth/dlf_signer.py      |   8 +
 .../pypaimon/tests/rest/dlf_signer_test.py         | 127 ++++++++++++++
 8 files changed, 672 insertions(+), 2 deletions(-)

diff --git a/docs/docs/concepts/rest/dlf.md b/docs/docs/concepts/rest/dlf.md
index 602555b4a8..dc8a79832d 100644
--- a/docs/docs/concepts/rest/dlf.md
+++ b/docs/docs/concepts/rest/dlf.md
@@ -145,7 +145,9 @@ The client selects a request signer from the configured 
endpoint unless
 
 URIs containing `dlfnext` select the OpenAPI signer; other URIs select the 
default signer. Set
 `dlf.signing-algorithm` to `openapi-v4` to sign with ACS4-HMAC-SHA256 instead, 
which hashes a
-canonical request and derives its key from the date, the region and the 
product. That signer is
-opt-in: no endpoint selects it on its own. The client also infers the region 
from the URI.
+canonical request and derives its key from the date, the region and the 
product. It also signs an
+`x-acs-action` header naming the DlfNext API that the request's method and 
path map to, which some
+gateways require before they route the call. That signer is opt-in: no 
endpoint selects it on its
+own. The client also infers the region from the URI.
 Set `dlf.region` explicitly if the endpoint does not contain a recognizable 
region, for example when
 using a custom hostname.
diff --git 
a/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFOpenApiActions.java 
b/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFOpenApiActions.java
new file mode 100644
index 0000000000..4d72b9c991
--- /dev/null
+++ 
b/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFOpenApiActions.java
@@ -0,0 +1,184 @@
+/*
+ * 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.
+ */
+
+package org.apache.paimon.rest.auth;
+
+import org.apache.paimon.annotation.VisibleForTesting;
+
+import javax.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/** Maps a REST request to the POP action it is registered under in DlfNext 
2026-01-18. */
+final class DLFOpenApiActions {
+
+    /** Method, path template and action; a {@code {name}} segment matches any 
one segment. */
+    private static final String[][] OPERATIONS = {
+        {"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}/databases/{database}/tables/{table}", 
"GetTable"},
+        {"POST", "/v1/{prefix}/databases/{database}/tables/{table}", 
"AlterTable"},
+        {"DELETE", "/v1/{prefix}/databases/{database}/tables/{table}", 
"DropTable"},
+        {"POST", "/v1/{prefix}/tables/rename", "RenameTable"},
+        {"POST", "/v1/{prefix}/databases/{database}/tables/{table}/auth", 
"AuthTableQuery"},
+        {"POST", "/v1/{prefix}/databases/{database}/tables/{table}/commit", 
"CommitTable"},
+        {"GET", "/v1/{prefix}/databases/{database}/tables/{table}/token", 
"GetTableToken"},
+        {"POST", "/v1/{prefix}/databases/{database}/tables/{table}/rollback", 
"RollbackToSnapshot"},
+        {"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}/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}/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}/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}/functions", 
"ListFunctions"},
+        {"POST", "/v1/{prefix}/databases/{database}/functions", 
"CreateFunction"},
+        {"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"},
+    };
+
+    private static final Map<String, List<Operation>> BY_METHOD = index();
+
+    private DLFOpenApiActions() {}
+
+    /** Returns the action for the request, or null when no operation is 
registered for it. */
+    @Nullable
+    static String resolve(@Nullable String method, @Nullable String 
resourcePath) {
+        if (method == null || resourcePath == null) {
+            return null;
+        }
+        List<Operation> candidates = 
BY_METHOD.get(method.toUpperCase(Locale.ROOT));
+        if (candidates == null) {
+            return null;
+        }
+        String[] segments = resourcePath.split("/", -1);
+        for (Operation operation : candidates) {
+            if (operation.matches(segments)) {
+                return operation.action;
+            }
+        }
+        return null;
+    }
+
+    /** Every registered operation as {method, path template, action}. */
+    @VisibleForTesting
+    static List<String[]> operations() {
+        List<String[]> copy = new ArrayList<>();
+        for (String[] operation : OPERATIONS) {
+            copy.add(operation.clone());
+        }
+        return copy;
+    }
+
+    private static Map<String, List<Operation>> index() {
+        Map<String, List<Operation>> byMethod = new HashMap<>();
+        for (String[] operation : OPERATIONS) {
+            byMethod.computeIfAbsent(operation[0], k -> new ArrayList<>())
+                    .add(new Operation(operation[1].split("/", -1), 
operation[2]));
+        }
+        return Collections.unmodifiableMap(byMethod);
+    }
+
+    private static final class Operation {
+        private final String[] template;
+        private final String action;
+
+        private Operation(String[] template, String action) {
+            this.template = template;
+            this.action = action;
+        }
+
+        private boolean matches(String[] segments) {
+            if (segments.length != template.length) {
+                return false;
+            }
+            for (int i = 0; i < template.length; i++) {
+                boolean variable = template[i].startsWith("{");
+                if (variable ? segments[i].isEmpty() : 
!template[i].equals(segments[i])) {
+                    return false;
+                }
+            }
+            return true;
+        }
+    }
+}
diff --git 
a/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFOpenApiV4Signer.java 
b/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFOpenApiV4Signer.java
index d82103a9ec..4c090c7a8b 100644
--- 
a/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFOpenApiV4Signer.java
+++ 
b/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFOpenApiV4Signer.java
@@ -46,6 +46,9 @@ import static org.apache.paimon.rest.RESTUtil.decodeString;
  * Content-MD5} headers with HMAC-SHA1, this signer hashes a canonical request 
and signs it with a
  * key derived from the date, the region and the product, the way the POP 
gateway does.
  *
+ * <p>{@link #authorization} also puts the API name in the headers it is 
handed, because a POP
+ * gateway may route by that header and it has to be signed with the rest.
+ *
  * <p>Reference: 
https://github.com/aliyun/alibabacloud-gateway/tree/master/alibabacloud-gateway-pop
  */
 public class DLFOpenApiV4Signer implements DLFRequestSigner {
@@ -70,6 +73,7 @@ public class DLFOpenApiV4Signer implements DLFRequestSigner {
     private static final String X_ACS_VERSION = "x-acs-version";
     private static final String X_ACS_CONTENT_SHA256 = "x-acs-content-sha256";
     private static final String X_ACS_SECURITY_TOKEN = "x-acs-security-token";
+    private static final String X_ACS_ACTION = "x-acs-action";
 
     private static final String CONTENT_TYPE_VALUE = "application/json";
     private static final String API_VERSION = "2026-01-18";
@@ -153,6 +157,15 @@ public class DLFOpenApiV4Signer implements 
DLFRequestSigner {
             throw new IllegalArgumentException("Parameter 'signHeaders' cannot 
be null");
         }
 
+        // POP gateways may route by this header, so it is added to the 
headers the caller sends
+        // and signed with them; the request names no API only when none is 
registered for it.
+        String action =
+                DLFOpenApiActions.resolve(
+                        restAuthParameter.method(), 
restAuthParameter.resourcePath());
+        if (action != null) {
+            signHeaders.put(X_ACS_ACTION, action);
+        }
+
         CanonicalHeadersResult canonicalHeadersResult = 
buildCanonicalHeaders(signHeaders);
         String canonicalQueryString = 
buildCanonicalQueryString(restAuthParameter.parameters());
 
diff --git 
a/paimon-api/src/test/java/org/apache/paimon/rest/auth/DLFOpenApiActionsTest.java
 
b/paimon-api/src/test/java/org/apache/paimon/rest/auth/DLFOpenApiActionsTest.java
new file mode 100644
index 0000000000..d9a11067b3
--- /dev/null
+++ 
b/paimon-api/src/test/java/org/apache/paimon/rest/auth/DLFOpenApiActionsTest.java
@@ -0,0 +1,141 @@
+/*
+ * 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.
+ */
+
+package org.apache.paimon.rest.auth;
+
+import org.apache.paimon.management.PermissionResource;
+import org.apache.paimon.management.ResourceType;
+import org.apache.paimon.rest.ResourcePaths;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/** Test for {@link DLFOpenApiActions}. */
+public class DLFOpenApiActionsTest {
+
+    private final ResourcePaths paths = new ResourcePaths("clg-paimon-1");
+
+    /** Each template, filled in, must come back to its own action rather than 
an earlier one. */
+    @Test
+    public void testEveryOperationResolvesToItself() {
+        Set<String> actions = new HashSet<>();
+        for (String[] operation : DLFOpenApiActions.operations()) {
+            String path = operation[1].replaceAll("\\{[^}]+}", "x1");
+            assertEquals(operation[2], DLFOpenApiActions.resolve(operation[0], 
path), path);
+            actions.add(operation[2]);
+        }
+        assertEquals(53, actions.size());
+    }
+
+    @Test
+    public void testClientPathsResolveToRegisteredActions() {
+        assertAction("GetConfig", "GET", ResourcePaths.config());
+        assertAction("ListDatabases", "GET", paths.databases());
+        assertAction("CreateDatabase", "POST", paths.databases());
+        assertAction("GetDatabase", "GET", paths.database("db"));
+        assertAction("AlterDatabase", "POST", paths.database("db"));
+        assertAction("DropDatabase", "DELETE", paths.database("db"));
+        assertAction("RegisterTable", "POST", paths.registerTable("db"));
+        assertAction("ListTables", "GET", paths.tables("db"));
+        assertAction("CreateTable", "POST", paths.tables("db"));
+        assertAction("ListTableDetails", "GET", paths.tableDetails("db"));
+        assertAction("GetTable", "GET", paths.table("db", "t"));
+        assertAction("AlterTable", "POST", paths.table("db", "t"));
+        assertAction("DropTable", "DELETE", paths.table("db", "t"));
+        assertAction("RenameTable", "POST", paths.renameTable());
+        assertAction("AuthTableQuery", "POST", paths.authTable("db", "t"));
+        assertAction("CommitTable", "POST", paths.commitTable("db", "t"));
+        assertAction("GetTableToken", "GET", paths.tableToken("db", "t"));
+        assertAction("RollbackToSnapshot", "POST", paths.rollbackTable("db", 
"t"));
+        assertAction("GetTableSnapshot", "GET", paths.tableSnapshot("db", 
"t"));
+        assertAction("ListSnapshots", "GET", paths.snapshots("db", "t"));
+        assertAction("GetVersionSnapshot", "GET", paths.tableSnapshot("db", 
"t", "3"));
+        assertAction("ListPartitions", "GET", paths.partitions("db", "t"));
+        assertAction("CreatePartitions", "POST", paths.partitions("db", "t"));
+        assertAction("DropPartitions", "POST", paths.dropPartitions("db", 
"t"));
+        assertAction("MarkDonePartitions", "POST", 
paths.markDonePartitions("db", "t"));
+        assertAction("ListPartitionsByNames", "POST", 
paths.listPartitionsByNames("db", "t"));
+        assertAction("ListPartitionsByFilter", "POST", 
paths.listPartitionsByFilter("db", "t"));
+        assertAction("ListBranches", "GET", paths.branches("db", "t"));
+        assertAction("CreateBranch", "POST", paths.branches("db", "t"));
+        assertAction("DropBranch", "DELETE", paths.branch("db", "t", "b"));
+        assertAction("FastForwardBranch", "POST", paths.forwardBranch("db", 
"t", "b"));
+        assertAction("ListTags", "GET", paths.tags("db", "t"));
+        assertAction("CreateTag", "POST", paths.tags("db", "t"));
+        assertAction("GetTag", "GET", paths.tag("db", "t", "tag"));
+        assertAction("DropTag", "DELETE", paths.tag("db", "t", "tag"));
+        PermissionResource table =
+                new PermissionResource(ResourceType.TABLE, "db", "t", null, 
null);
+        assertAction("ListPolicies", "GET", paths.policies(table));
+        assertAction("CreatePolicy", "POST", paths.policies(table));
+        assertAction("DropPolicy", "POST", paths.dropPolicy(table));
+        assertAction("ListViews", "GET", paths.views("db"));
+        assertAction("CreateView", "POST", paths.views("db"));
+        assertAction("ListViewDetails", "GET", paths.viewDetails("db"));
+        assertAction("GetView", "GET", paths.view("db", "v"));
+        assertAction("AlterView", "POST", paths.view("db", "v"));
+        assertAction("DropView", "DELETE", paths.view("db", "v"));
+        assertAction("RenameView", "POST", paths.renameView());
+        assertAction("ListFunctions", "GET", paths.functions("db"));
+        assertAction("CreateFunction", "POST", paths.functions("db"));
+        assertAction("GetFunction", "GET", paths.function("db", "f"));
+        assertAction("AlterFunction", "POST", paths.function("db", "f"));
+        assertAction("DropFunction", "DELETE", paths.function("db", "f"));
+        assertAction("ListPermissionAssignments", "GET", paths.permissions());
+        assertAction("GrantPermissionAssignment", "POST", 
paths.grantPermission());
+        assertAction("RevokePermissionAssignment", "POST", 
paths.revokePermission());
+    }
+
+    /** Names match whole segments, so one spelled like a literal still 
resolves. */
+    @Test
+    public void testNamesSpelledLikeLiterals() {
+        assertAction("GetDatabase", "GET", paths.database("tables"));
+        assertAction("GetTable", "GET", paths.table("db", "token"));
+        assertAction("DropBranch", "DELETE", paths.branch("db", "t", 
"forward"));
+        assertAction("GetTag", "GET", paths.tag("db", "t", "tags"));
+        assertAction("GetTableToken", "GET", paths.tableToken("config", 
"t$snapshots"));
+        assertAction("GetTable", "GET", paths.table("a/b", "c d"));
+        assertAction("ListDatabases", "GET", new 
ResourcePaths("rename").databases());
+    }
+
+    @Test
+    public void testUnregisteredRequestsHaveNoAction() {
+        assertNull(DLFOpenApiActions.resolve("GET", paths.tables()));
+        assertNull(DLFOpenApiActions.resolve("GET", paths.table("tbl-1")));
+        assertNull(DLFOpenApiActions.resolve("GET", paths.schemas("db", "t")));
+        assertNull(DLFOpenApiActions.resolve("PUT", paths.table("db", "t")));
+        assertNull(DLFOpenApiActions.resolve("GET", ResourcePaths.config() + 
"/"));
+        assertNull(DLFOpenApiActions.resolve("GET", "/v1//databases"));
+        assertNull(DLFOpenApiActions.resolve(null, ResourcePaths.config()));
+        assertNull(DLFOpenApiActions.resolve("GET", null));
+    }
+
+    @Test
+    public void testMethodIsCaseInsensitive() {
+        assertAction("GetConfig", "get", ResourcePaths.config());
+    }
+
+    private static void assertAction(String expected, String method, String 
path) {
+        assertEquals(expected, DLFOpenApiActions.resolve(method, path), method 
+ " " + path);
+    }
+}
diff --git 
a/paimon-api/src/test/java/org/apache/paimon/rest/auth/DLFOpenApiV4SignerTest.java
 
b/paimon-api/src/test/java/org/apache/paimon/rest/auth/DLFOpenApiV4SignerTest.java
index 540b2c82db..a81358aa88 100644
--- 
a/paimon-api/src/test/java/org/apache/paimon/rest/auth/DLFOpenApiV4SignerTest.java
+++ 
b/paimon-api/src/test/java/org/apache/paimon/rest/auth/DLFOpenApiV4SignerTest.java
@@ -227,6 +227,98 @@ public class DLFOpenApiV4SignerTest {
                 authorization);
     }
 
+    /** Known answer with a body and a resolved action; pypaimon pins the same 
string. */
+    @Test
+    public void testKnownSignatureWithAction() throws Exception {
+        DLFOpenApiV4Signer signer = new DLFOpenApiV4Signer("cn-hangzhou");
+        DLFToken token = new DLFToken("TestAKId", "TestAKSecret", null, null);
+        String host = "dlfnext.cn-hangzhou.aliyuncs.com";
+        String body = 
"{\"identifier\":{\"database\":\"db\",\"object\":\"t\"}}";
+        RESTAuthParameter restAuthParameter =
+                new RESTAuthParameter(
+                        "/v1/clg-paimon-1/databases/db/tables", new 
HashMap<>(), "POST", body);
+
+        Map<String, String> signHeaders = signer.signHeaders(body, NOW, null, 
host);
+        signHeaders.put("x-acs-signature-nonce", "fixed-nonce-for-test");
+        String authorization = signer.authorization(restAuthParameter, token, 
host, signHeaders);
+
+        // signing adds the action to the headers the caller then sends
+        assertEquals("CreateTable", signHeaders.get("x-acs-action"));
+        assertEquals(
+                "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
+    public void testUnregisteredPathIsSignedWithoutAnAction() throws Exception 
{
+        DLFOpenApiV4Signer signer = new DLFOpenApiV4Signer(REGION);
+        DLFToken token = new DLFToken("TestAKId", "TestAKSecret", null, null);
+        RESTAuthParameter restAuthParameter =
+                new RESTAuthParameter("/v1/clg-paimon-1/tables", new 
HashMap<>(), "GET", null);
+        Map<String, String> signHeaders = signer.signHeaders(null, NOW, null, 
HOST);
+
+        String authorization = signer.authorization(restAuthParameter, token, 
HOST, signHeaders);
+
+        assertFalse(signHeaders.containsKey("x-acs-action"));
+        assertFalse(authorization.contains("x-acs-action"));
+    }
+
+    /** Only the ACS4 signer names the API; the other schemes send what they 
always did. */
+    @Test
+    public void testOnlyOpenApiV4SendsAction() {
+        RESTAuthParameter restAuthParameter =
+                new RESTAuthParameter(
+                        "/v1/clg-paimon-1/databases/db/tables/t/token",
+                        new HashMap<>(),
+                        "GET",
+                        null);
+        for (String algorithm :
+                new String[] {DLFDefaultSigner.IDENTIFIER, 
DLFOpenApiSigner.IDENTIFIER}) {
+            DLFAuthProvider provider =
+                    DLFAuthProvider.fromAccessKey(
+                            "akId",
+                            "akSecret",
+                            null,
+                            "https://dlfnext.cn-hangzhou.aliyuncs.com";,
+                            "cn-hangzhou",
+                            algorithm);
+            Map<String, String> headers =
+                    provider.mergeAuthHeader(new HashMap<>(), 
restAuthParameter);
+            assertFalse(headers.containsKey("x-acs-action"), algorithm);
+        }
+    }
+
+    @Test
+    public void testAuthProviderSendsSignedAction() {
+        DLFAuthProvider provider =
+                DLFAuthProvider.fromAccessKey(
+                        "akId",
+                        "akSecret",
+                        "securityToken",
+                        "https://dlfnext.cn-hangzhou.aliyuncs.com";,
+                        "cn-hangzhou",
+                        DLFOpenApiV4Signer.IDENTIFIER);
+
+        Map<String, String> headers =
+                provider.mergeAuthHeader(
+                        new HashMap<>(),
+                        new RESTAuthParameter(
+                                "/v1/clg-paimon-1/databases/db/tables/t/token",
+                                new HashMap<>(),
+                                "GET",
+                                null));
+
+        assertEquals("GetTableToken", headers.get("x-acs-action"));
+        assertTrue(
+                headers.get("Authorization")
+                        .contains(
+                                
"SignedHeaders=host;x-acs-action;x-acs-content-sha256;x-acs-date;"
+                                        + 
"x-acs-security-token;x-acs-signature-nonce;x-acs-version,"));
+    }
+
     @Test
     public void testSignedHeadersIncludeHostAndContentType() throws Exception {
         DLFOpenApiV4Signer signer = new DLFOpenApiV4Signer(REGION);
diff --git a/paimon-python/pypaimon/api/auth/dlf_openapi_actions.py 
b/paimon-python/pypaimon/api/auth/dlf_openapi_actions.py
new file mode 100644
index 0000000000..8fd46e9f63
--- /dev/null
+++ b/paimon-python/pypaimon/api/auth/dlf_openapi_actions.py
@@ -0,0 +1,103 @@
+# 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.
+
+"""Maps a REST request to the POP action it is registered under in DlfNext 
2026-01-18."""
+
+from typing import Dict, List, Optional, Tuple
+
+# Method, path template and action; a {name} segment matches any one segment.
+OPERATIONS: Tuple[Tuple[str, str, str], ...] = (
+    ("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}/databases/{database}/tables/{table}", "GetTable"),
+    ("POST", "/v1/{prefix}/databases/{database}/tables/{table}", "AlterTable"),
+    ("DELETE", "/v1/{prefix}/databases/{database}/tables/{table}", 
"DropTable"),
+    ("POST", "/v1/{prefix}/tables/rename", "RenameTable"),
+    ("POST", "/v1/{prefix}/databases/{database}/tables/{table}/auth", 
"AuthTableQuery"),
+    ("POST", "/v1/{prefix}/databases/{database}/tables/{table}/commit", 
"CommitTable"),
+    ("GET", "/v1/{prefix}/databases/{database}/tables/{table}/token", 
"GetTableToken"),
+    ("POST", "/v1/{prefix}/databases/{database}/tables/{table}/rollback", 
"RollbackToSnapshot"),
+    ("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}/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}/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}/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}/functions", "ListFunctions"),
+    ("POST", "/v1/{prefix}/databases/{database}/functions", "CreateFunction"),
+    ("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"),
+)
+
+
+def _index() -> Dict[str, List[Tuple[List[str], str]]]:
+    by_method: Dict[str, List[Tuple[List[str], str]]] = {}
+    for method, template, action in OPERATIONS:
+        by_method.setdefault(method, []).append((template.split("/"), action))
+    return by_method
+
+
+_BY_METHOD = _index()
+
+
+def resolve_action(method: Optional[str], path: Optional[str]) -> 
Optional[str]:
+    """Returns the action for the request, or None when no operation is 
registered for it."""
+    if method is None or path is None:
+        return None
+    segments = path.split("/")
+    for template, action in _BY_METHOD.get(method.upper(), []):
+        if len(template) == len(segments) and all(
+                segment != "" if part.startswith("{") else part == segment
+                for part, segment in zip(template, segments)):
+            return action
+    return None
diff --git a/paimon-python/pypaimon/api/auth/dlf_signer.py 
b/paimon-python/pypaimon/api/auth/dlf_signer.py
index 9915fa012d..fa69c16f6b 100644
--- a/paimon-python/pypaimon/api/auth/dlf_signer.py
+++ b/paimon-python/pypaimon/api/auth/dlf_signer.py
@@ -27,6 +27,7 @@ from datetime import datetime, timezone
 from typing import Dict, NamedTuple, Optional
 from urllib.parse import quote, unquote
 
+from pypaimon.api.auth.dlf_openapi_actions import resolve_action
 from pypaimon.api.token_loader import DLFToken
 from pypaimon.api.typedef import RESTAuthParameter
 
@@ -536,6 +537,7 @@ class DLFOpenApiV4Signer(DLFRequestSigner):
     X_ACS_VERSION = "x-acs-version"
     X_ACS_CONTENT_SHA256 = "x-acs-content-sha256"
     X_ACS_SECURITY_TOKEN = "x-acs-security-token"
+    X_ACS_ACTION = "x-acs-action"
 
     # Values
     ACS_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
@@ -604,6 +606,12 @@ class DLFOpenApiV4Signer(DLFRequestSigner):
             raise ValueError("Parameter 'sign_headers' cannot be None")
 
         try:
+            # POP gateways may route by this header, so it is added to the 
headers the caller
+            # sends and signed with them; it is absent only when no API is 
registered for it.
+            action = resolve_action(rest_auth_parameter.method, 
rest_auth_parameter.path)
+            if action is not None:
+                sign_headers[self.X_ACS_ACTION] = action
+
             canonical = self._build_canonical_headers(sign_headers)
             canonical_query_string = self._build_canonical_query_string(
                 rest_auth_parameter.parameters
diff --git a/paimon-python/pypaimon/tests/rest/dlf_signer_test.py 
b/paimon-python/pypaimon/tests/rest/dlf_signer_test.py
index 4b9ef61e95..53368b8f54 100644
--- a/paimon-python/pypaimon/tests/rest/dlf_signer_test.py
+++ b/paimon-python/pypaimon/tests/rest/dlf_signer_test.py
@@ -29,6 +29,8 @@ from pypaimon.api.auth import (
     DLFOpenApiSigner,
     DLFOpenApiV4Signer,
 )
+from pypaimon.api.auth.dlf_openapi_actions import OPERATIONS, resolve_action
+from pypaimon.api.resource_paths import ResourcePaths
 from pypaimon.api.token_loader import DLFToken
 from pypaimon.api.typedef import RESTAuthParameter
 
@@ -479,6 +481,131 @@ class DLFSignerTest(unittest.TestCase):
         self.assertEqual(
             hashlib.sha256(b"").hexdigest(), 
DLFOpenApiV4Signer.EMPTY_BODY_SHA256)
 
+    def test_openapi_v4_every_operation_resolves_to_itself(self):
+        """Each template, filled in, must come back to its own action rather 
than an earlier one."""
+        actions = set()
+        for method, template, action in OPERATIONS:
+            path = re.sub(r"\{[^}]+\}", "x1", template)
+            self.assertEqual(action, resolve_action(method, path), path)
+            actions.add(action)
+        self.assertEqual(53, len(actions))
+
+    def test_openapi_v4_client_paths_resolve_to_registered_actions(self):
+        paths = ResourcePaths("clg-paimon-1")
+        cases = [
+            ("GetConfig", "GET", ResourcePaths.config()),
+            ("ListDatabases", "GET", paths.databases()),
+            ("CreateDatabase", "POST", paths.databases()),
+            ("GetDatabase", "GET", paths.database("db")),
+            ("DropDatabase", "DELETE", paths.database("db")),
+            ("ListTables", "GET", paths.tables("db")),
+            ("CreateTable", "POST", paths.tables("db")),
+            ("ListTableDetails", "GET", paths.table_details("db")),
+            ("GetTable", "GET", paths.table("db", "t")),
+            ("AlterTable", "POST", paths.table("db", "t")),
+            ("DropTable", "DELETE", paths.table("db", "t")),
+            ("RenameTable", "POST", paths.rename_table()),
+            ("GetTableToken", "GET", paths.table_token("db", "t")),
+            ("CommitTable", "POST", paths.commit_table("db", "t")),
+            ("RollbackToSnapshot", "POST", paths.rollback_table("db", "t")),
+            ("GetTableSnapshot", "GET", paths.table_snapshot("db", "t")),
+            ("ListPartitions", "GET", paths.partitions("db", "t")),
+            ("ListFunctions", "GET", paths.functions("db")),
+            ("GetFunction", "GET", paths.function("db", "f")),
+            ("ListTags", "GET", paths.tags("db", "t")),
+            ("GetTag", "GET", paths.tag("db", "t", "tag")),
+            ("ListBranches", "GET", paths.branches("db", "t")),
+            ("DropBranch", "DELETE", paths.branch("db", "t", "b")),
+            ("FastForwardBranch", "POST", paths.forward_branch("db", "t", 
"b")),
+            ("AuthTableQuery", "POST", paths.auth_table("db", "t")),
+        ]
+        for action, method, path in cases:
+            self.assertEqual(action, resolve_action(method, path), method + " 
" + path)
+
+    def test_openapi_v4_names_spelled_like_literals(self):
+        """Names match whole segments, so one spelled like a literal still 
resolves."""
+        paths = ResourcePaths("clg-paimon-1")
+        self.assertEqual("GetDatabase", resolve_action("GET", 
paths.database("tables")))
+        self.assertEqual("GetTable", resolve_action("GET", paths.table("db", 
"token")))
+        self.assertEqual("DropBranch", resolve_action("DELETE", 
paths.branch("db", "t", "forward")))
+        self.assertEqual("GetTableToken", resolve_action("GET", 
paths.table_token("config", "t$snapshots")))
+        self.assertEqual("GetTable", resolve_action("GET", 
"/v1/clg-paimon-1/databases/a%2Fb/tables/c%20d"))
+        self.assertEqual("ListDatabases", resolve_action("get", 
ResourcePaths("rename").databases()))
+
+    def test_openapi_v4_unregistered_requests_have_no_action(self):
+        paths = ResourcePaths("clg-paimon-1")
+        self.assertIsNone(resolve_action("GET", paths.tables()))
+        self.assertIsNone(resolve_action("POST", paths.rename_branch("db", 
"t", "b")))
+        self.assertIsNone(resolve_action("PUT", paths.table("db", "t")))
+        self.assertIsNone(resolve_action("GET", ResourcePaths.config() + "/"))
+        self.assertIsNone(resolve_action("GET", "/v1//databases"))
+        self.assertIsNone(resolve_action(None, ResourcePaths.config()))
+        self.assertIsNone(resolve_action("GET", None))
+
+    def test_openapi_v4_known_signature_with_action(self):
+        """Known answer with a body and a resolved action. The Java signer 
pins the same string."""
+        signer = DLFOpenApiV4Signer("cn-hangzhou")
+        token = DLFToken("TestAKId", "TestAKSecret", None, None)
+        host = "dlfnext.cn-hangzhou.aliyuncs.com"
+        now = datetime(2025, 4, 16, 3, 44, 46, tzinfo=timezone.utc)
+        body = '{"identifier":{"database":"db","object":"t"}}'
+        rest_param = RESTAuthParameter("POST", 
"/v1/clg-paimon-1/databases/db/tables", body, {})
+
+        headers = signer.sign_headers(body, now, None, host)
+        headers["x-acs-signature-nonce"] = "fixed-nonce-for-test"
+        authorization = signer.authorization(rest_param, token, host, headers)
+
+        # signing adds the action to the headers the caller then sends
+        self.assertEqual("CreateTable", headers["x-acs-action"])
+        self.assertEqual(
+            "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)
+
+    def test_openapi_v4_unregistered_path_is_signed_without_an_action(self):
+        signer = DLFOpenApiV4Signer("cn-hangzhou")
+        token = DLFToken("TestAKId", "TestAKSecret", None, None)
+        host = "dlfnext.cn-hangzhou.aliyuncs.com"
+        now = datetime(2025, 4, 16, 3, 44, 46, tzinfo=timezone.utc)
+        rest_param = RESTAuthParameter("GET", "/v1/clg-paimon-1/tables", "", 
{})
+        headers = signer.sign_headers(None, now, None, host)
+
+        authorization = signer.authorization(rest_param, token, host, headers)
+
+        self.assertNotIn("x-acs-action", headers)
+        self.assertNotIn("x-acs-action", authorization)
+
+    def test_auth_provider_sends_signed_action(self):
+        provider = DLFAuthProvider(
+            uri="https://dlfnext.cn-hangzhou.aliyuncs.com";,
+            region="cn-hangzhou",
+            signing_algorithm=DLFOpenApiV4Signer.IDENTIFIER,
+            token=DLFToken("akId", "akSecret", "securityToken", None)
+        )
+
+        header = provider.merge_auth_header(
+            {}, RESTAuthParameter("GET", 
"/v1/clg-paimon-1/databases/db/tables/t/token", "", {}))
+
+        self.assertEqual("GetTableToken", header["x-acs-action"])
+        self.assertIn(
+            "SignedHeaders=host;x-acs-action;x-acs-content-sha256;x-acs-date;"
+            "x-acs-security-token;x-acs-signature-nonce;x-acs-version,",
+            header["Authorization"])
+
+    def test_only_openapi_v4_sends_action(self):
+        """Only the ACS4 signer resolves an action; the other schemes send 
what they always did."""
+        rest_param = RESTAuthParameter("GET", 
"/v1/clg-paimon-1/databases/db/tables/t/token", "", {})
+        for algorithm in (DLFDefaultSigner.IDENTIFIER, 
DLFOpenApiSigner.IDENTIFIER):
+            provider = DLFAuthProvider(
+                uri="https://dlfnext.cn-hangzhou.aliyuncs.com";,
+                region="cn-hangzhou",
+                signing_algorithm=algorithm,
+                token=DLFToken("akId", "akSecret", None, None)
+            )
+            self.assertNotIn("x-acs-action", provider.merge_auth_header({}, 
rest_param), algorithm)
+
 
 if __name__ == '__main__':
     unittest.main()

Reply via email to