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()
