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 507a1211a6 [python] Support DLF token path authentication (#9089)
507a1211a6 is described below

commit 507a1211a62d1fd9c97e200a6f4326e5a9a9f0b0
Author: Joey <[email protected]>
AuthorDate: Fri Aug 7 22:52:04 2026 +0800

    [python] Support DLF token path authentication (#9089)
---
 paimon-python/pypaimon/api/auth/factory.py         |  10 +-
 paimon-python/pypaimon/api/token_loader.py         |  55 ++++++-
 paimon-python/pypaimon/common/options/config.py    |   2 +
 .../pypaimon/tests/rest/token_loader_test.py       | 172 +++++++++++++++++++++
 4 files changed, 236 insertions(+), 3 deletions(-)

diff --git a/paimon-python/pypaimon/api/auth/factory.py 
b/paimon-python/pypaimon/api/auth/factory.py
index e9c92e420b..815ca12635 100644
--- a/paimon-python/pypaimon/api/auth/factory.py
+++ b/paimon-python/pypaimon/api/auth/factory.py
@@ -94,11 +94,17 @@ class AuthProviderFactory:
                 # Auto-detect based on URI
                 signing_algorithm = 
DLFAuthProviderFactory.parse_signing_algo_from_uri(uri)
 
+            token_loader = DLFTokenLoaderFactory.create_token_loader(options)
+            token = (
+                DLFToken.from_options(options)
+                if token_loader is None
+                else None
+            )
             return DLFAuthProvider(
                 uri=uri,
                 region=region,
                 signing_algorithm=signing_algorithm,
-                token=DLFToken.from_options(options),
-                token_loader=DLFTokenLoaderFactory.create_token_loader(options)
+                token=token,
+                token_loader=token_loader
             )
         raise ValueError('Unknown auth provider')
diff --git a/paimon-python/pypaimon/api/token_loader.py 
b/paimon-python/pypaimon/api/token_loader.py
index 65aad3677a..f76eca4306 100644
--- a/paimon-python/pypaimon/api/token_loader.py
+++ b/paimon-python/pypaimon/api/token_loader.py
@@ -15,6 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import time
 from abc import ABC, abstractmethod
 from dataclasses import dataclass
 from datetime import datetime, timezone
@@ -84,6 +85,49 @@ class DLFTokenLoader(ABC):
         pass
 
 
+class DLFLocalFileTokenLoader(DLFTokenLoader):
+    """Load temporary DLF credentials from a local JSON file."""
+
+    DEFAULT_MAX_RETRIES = 5
+
+    def __init__(self, token_file_path: str):
+        self.token_file_path = token_file_path
+
+    def load_token(self) -> DLFToken:
+        return self.read_token(self.token_file_path)
+
+    def description(self) -> str:
+        return self.token_file_path
+
+    @staticmethod
+    def read_token(token_file_path: str,
+                   max_retries: int = DEFAULT_MAX_RETRIES) -> DLFToken:
+        retry = 1
+        last_exception = None
+        while retry <= max_retries:
+            try:
+                with open(token_file_path, "r", encoding="utf-8") as 
token_file:
+                    token_json = token_file.read()
+            except Exception as e:
+                last_exception = RuntimeError(
+                    "Failed to read token file: {}".format(token_file_path)
+                )
+                last_exception.__cause__ = e
+            else:
+                try:
+                    return JSON.from_json(token_json, DLFToken)
+                except Exception:
+                    # The file contains AK/SK/STS. Do not retain the JSON
+                    # parser exception because it can include source content.
+                    last_exception = RuntimeError("Failed to parse token 
file.")
+
+            if retry < max_retries:
+                time.sleep(retry)
+            retry += 1
+
+        raise last_exception
+
+
 class HTTPClient:
     """HTTP client with retry and timeout configuration"""
 
@@ -207,7 +251,7 @@ class DLFTokenLoaderFactory:
 
     @staticmethod
     def create_token_loader(options: Options) -> Optional['DLFTokenLoader']:
-        """Create ECS token loader"""
+        """Create the configured token loader."""
         loader = options.get(CatalogOptions.DLF_TOKEN_LOADER)
         if loader == 'ecs':
             ecs_metadata_url = options.get(
@@ -216,4 +260,13 @@ class DLFTokenLoaderFactory:
             )
             role_name = options.get(CatalogOptions.DLF_TOKEN_ECS_ROLE_NAME)
             return DLFECSTokenLoader(ecs_metadata_url, role_name)
+        if loader == 'local_file':
+            return DLFLocalFileTokenLoader(
+                options.get(CatalogOptions.DLF_TOKEN_PATH)
+            )
+        if loader is not None:
+            raise ValueError("Unknown DLF token loader: {}".format(loader))
+        token_path = options.get(CatalogOptions.DLF_TOKEN_PATH)
+        if token_path is not None:
+            return DLFLocalFileTokenLoader(token_path)
         return None
diff --git a/paimon-python/pypaimon/common/options/config.py 
b/paimon-python/pypaimon/common/options/config.py
index 2573782c42..5684ab62c3 100644
--- a/paimon-python/pypaimon/common/options/config.py
+++ b/paimon-python/pypaimon/common/options/config.py
@@ -101,6 +101,8 @@ class CatalogOptions:
         
"dlf.access-key-secret").string_type().no_default_value().with_description("DLF 
access key secret")
     DLF_ACCESS_SECURITY_TOKEN = ConfigOptions.key(
         
"dlf.security-token").string_type().no_default_value().with_description("DLF 
security token")
+    DLF_TOKEN_PATH = 
ConfigOptions.key("dlf.token-path").string_type().no_default_value().with_description(
+        "DLF token file path")
     DLF_OSS_ENDPOINT = 
ConfigOptions.key("dlf.oss-endpoint").string_type().no_default_value().with_description(
         "DLF OSS endpoint")
     DLF_TOKEN_LOADER = 
ConfigOptions.key("dlf.token-loader").string_type().no_default_value().with_description(
diff --git a/paimon-python/pypaimon/tests/rest/token_loader_test.py 
b/paimon-python/pypaimon/tests/rest/token_loader_test.py
new file mode 100644
index 0000000000..1fb1d29f61
--- /dev/null
+++ b/paimon-python/pypaimon/tests/rest/token_loader_test.py
@@ -0,0 +1,172 @@
+# 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.
+
+import tempfile
+import unittest
+from pathlib import Path
+
+from pypaimon.api.auth.factory import AuthProviderFactory
+from pypaimon.api.token_loader import (
+    DLFLocalFileTokenLoader,
+    DLFToken,
+    DLFTokenLoaderFactory,
+)
+from pypaimon.common.json_util import JSON
+from pypaimon.common.options import Options
+from pypaimon.common.options.config import CatalogOptions
+
+
+class DLFLocalFileTokenLoaderTest(unittest.TestCase):
+
+    def test_load_token_from_configured_path(self):
+        token = DLFToken(
+            access_key_id="access-key-id",
+            access_key_secret="access-key-secret",
+            security_token="security-token",
+            expiration="2099-12-01T12:00:00Z",
+        )
+
+        with tempfile.TemporaryDirectory() as temp_dir:
+            token_path = Path(temp_dir) / "token.json"
+            token_path.write_text(JSON.to_json(token), encoding="utf-8")
+            options = Options({CatalogOptions.DLF_TOKEN_PATH.key(): 
str(token_path)})
+
+            loader = DLFTokenLoaderFactory.create_token_loader(options)
+            loaded_token = loader.load_token()
+
+            self.assertIsInstance(loader, DLFLocalFileTokenLoader)
+            self.assertEqual(str(token_path), loader.description())
+            self.assertEqual(token.access_key_id, loaded_token.access_key_id)
+            self.assertEqual(token.access_key_secret, 
loaded_token.access_key_secret)
+            self.assertEqual(token.security_token, loaded_token.security_token)
+            self.assertEqual(token.expiration, loaded_token.expiration)
+
+    def test_auth_provider_uses_token_path_without_explicit_loader(self):
+        token = DLFToken("access-key-id", "access-key-secret", 
"security-token")
+
+        with tempfile.TemporaryDirectory() as temp_dir:
+            token_path = Path(temp_dir) / "token.json"
+            token_path.write_text(JSON.to_json(token), encoding="utf-8")
+            options = Options({
+                CatalogOptions.URI.key():
+                    "https://cn-hangzhou-vpc.dlf.aliyuncs.com";,
+                CatalogOptions.TOKEN_PROVIDER.key(): "dlf",
+                CatalogOptions.DLF_TOKEN_PATH.key(): str(token_path),
+            })
+
+            provider = AuthProviderFactory.create_auth_provider(options)
+
+            self.assertIsInstance(provider.token_loader, 
DLFLocalFileTokenLoader)
+            self.assertEqual("access-key-id", 
provider.get_token().access_key_id)
+
+    def test_token_loader_takes_precedence_over_static_credentials(self):
+        file_token = DLFToken("file-ak", "file-sk", "file-sts")
+
+        with tempfile.TemporaryDirectory() as temp_dir:
+            token_path = Path(temp_dir) / "token.json"
+            token_path.write_text(JSON.to_json(file_token), encoding="utf-8")
+
+            for loader_name in (None, "local_file"):
+                with self.subTest(loader_name=loader_name):
+                    option_values = {
+                        CatalogOptions.URI.key():
+                            "https://cn-hangzhou-vpc.dlf.aliyuncs.com";,
+                        CatalogOptions.TOKEN_PROVIDER.key(): "dlf",
+                        CatalogOptions.DLF_TOKEN_PATH.key(): str(token_path),
+                        CatalogOptions.DLF_ACCESS_KEY_ID.key(): "static-ak",
+                        CatalogOptions.DLF_ACCESS_KEY_SECRET.key(): 
"static-sk",
+                    }
+                    if loader_name is not None:
+                        option_values[
+                            CatalogOptions.DLF_TOKEN_LOADER.key()
+                        ] = loader_name
+
+                    provider = AuthProviderFactory.create_auth_provider(
+                        Options(option_values)
+                    )
+
+                    self.assertEqual("file-ak", 
provider.get_token().access_key_id)
+
+    def 
test_unknown_token_loader_does_not_fallback_to_static_credentials(self):
+        options = Options({
+            CatalogOptions.URI.key():
+                "https://cn-hangzhou-vpc.dlf.aliyuncs.com";,
+            CatalogOptions.TOKEN_PROVIDER.key(): "dlf",
+            CatalogOptions.DLF_TOKEN_LOADER.key(): "unknown",
+            CatalogOptions.DLF_ACCESS_KEY_ID.key(): "static-ak",
+            CatalogOptions.DLF_ACCESS_KEY_SECRET.key(): "static-sk",
+        })
+
+        with self.assertRaisesRegex(ValueError, "Unknown DLF token loader: 
unknown"):
+            AuthProviderFactory.create_auth_provider(options)
+
+    def test_loader_reads_rotated_token(self):
+        first_token = DLFToken("first-ak", "first-sk", "first-sts")
+        second_token = DLFToken("second-ak", "second-sk", "second-sts")
+
+        with tempfile.TemporaryDirectory() as temp_dir:
+            token_path = Path(temp_dir) / "token.json"
+            token_path.write_text(JSON.to_json(first_token), encoding="utf-8")
+            loader = DLFLocalFileTokenLoader(str(token_path))
+
+            self.assertEqual("first-ak", loader.load_token().access_key_id)
+            token_path.write_text(JSON.to_json(second_token), encoding="utf-8")
+            self.assertEqual("second-ak", loader.load_token().access_key_id)
+
+    def test_auth_provider_reloads_expiring_token_from_path(self):
+        expired_token = DLFToken(
+            "first-ak", "first-sk", "first-sts", "2000-01-01T00:00:00Z"
+        )
+        fresh_token = DLFToken(
+            "second-ak", "second-sk", "second-sts", "2099-01-01T00:00:00Z"
+        )
+
+        with tempfile.TemporaryDirectory() as temp_dir:
+            token_path = Path(temp_dir) / "token.json"
+            token_path.write_text(JSON.to_json(expired_token), 
encoding="utf-8")
+            options = Options({
+                CatalogOptions.URI.key():
+                    "https://cn-hangzhou-vpc.dlf.aliyuncs.com";,
+                CatalogOptions.TOKEN_PROVIDER.key(): "dlf",
+                CatalogOptions.DLF_TOKEN_PATH.key(): str(token_path),
+            })
+            provider = AuthProviderFactory.create_auth_provider(options)
+
+            self.assertEqual("first-ak", provider.get_token().access_key_id)
+            token_path.write_text(JSON.to_json(fresh_token), encoding="utf-8")
+            self.assertEqual("second-ak", provider.get_token().access_key_id)
+
+    def test_malformed_token_file_does_not_leak_credentials(self):
+        secret = "STSSECRET_AKID_9999"
+
+        with tempfile.TemporaryDirectory() as temp_dir:
+            token_path = Path(temp_dir) / "token.json"
+            token_path.write_text(
+                '{"AccessKeyId":"akid","AccessKeySecret":"%s" INVALID_JSON'
+                % secret,
+                encoding="utf-8",
+            )
+
+            with self.assertRaisesRegex(RuntimeError, "Failed to parse token 
file") as ctx:
+                DLFLocalFileTokenLoader.read_token(str(token_path), 
max_retries=1)
+
+            self.assertNotIn(secret, str(ctx.exception))
+            self.assertIsNone(ctx.exception.__cause__)
+
+
+if __name__ == "__main__":
+    unittest.main()

Reply via email to