This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 109b7d6004 [api] Sign x-acs-action in DLFOpenApiV4Signer (#9937)
109b7d6004 is described below
commit 109b7d60042e9b516de64dd512b2a4b68a82a60e
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()