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

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 52b4bb23 [auth] Support local-file DLF token loading (#936)
52b4bb23 is described below

commit 52b4bb2362685b09df1f1ea3a42b4d856b0f4a55
Author: XiaoHongbo <[email protected]>
AuthorDate: Mon Sep 28 19:42:01 2026 +0800

    [auth] Support local-file DLF token loading (#936)
---
 crates/paimon/src/api/auth/dlf_provider.rs | 356 +++++++++++++++++++++++++----
 crates/paimon/src/api/auth/factory.rs      |  14 +-
 crates/paimon/src/api/auth/mod.rs          |   4 +-
 crates/paimon/src/common/options.rs        |   5 +-
 crates/paimon/tests/rest_catalog_test.rs   |  36 ++-
 docs/src/getting-started.md                |  10 +
 6 files changed, 376 insertions(+), 49 deletions(-)

diff --git a/crates/paimon/src/api/auth/dlf_provider.rs 
b/crates/paimon/src/api/auth/dlf_provider.rs
index 7dd09ae2..fc95eea9 100644
--- a/crates/paimon/src/api/auth/dlf_provider.rs
+++ b/crates/paimon/src/api/auth/dlf_provider.rs
@@ -47,8 +47,8 @@ pub struct DLFToken {
     /// Security token for temporary credentials (optional).
     #[serde(rename = "SecurityToken")]
     pub security_token: Option<String>,
-    /// Expiration timestamp in milliseconds.
-    #[serde(rename = "ExpirationAt", default, skip_serializing)]
+    /// Expiration timestamp in milliseconds used internally for refresh 
decisions.
+    #[serde(skip)]
     pub expiration_at_millis: Option<i64>,
     /// Expiration time string (ISO 8601 format).
     #[serde(
@@ -122,6 +122,29 @@ impl DLFToken {
             .and_utc();
         Some(datetime.timestamp_millis())
     }
+
+    fn from_json(token_json: &str) -> Result<Self> {
+        let mut token: Self = serde_json::from_str(token_json).map_err(|_| 
Error::DataInvalid {
+            message: "Failed to parse token JSON".to_string(),
+            source: None,
+        })?;
+        if token.access_key_id.trim().is_empty() || 
token.access_key_secret.trim().is_empty() {
+            return Err(Error::DataInvalid {
+                message: "DLF token access key ID and secret must not be 
empty".to_string(),
+                source: None,
+            });
+        }
+        if let Some(expiration) = token.expiration.as_deref() {
+            token.expiration_at_millis =
+                
Some(Self::parse_expiration_to_millis(expiration).ok_or_else(|| {
+                    Error::DataInvalid {
+                        message: "Failed to parse token 
Expiration".to_string(),
+                        source: None,
+                    }
+                })?);
+        }
+        Ok(token)
+    }
 }
 /// Trait for DLF token loaders.
 #[async_trait]
@@ -133,6 +156,58 @@ pub trait DLFTokenLoader: Send + Sync {
     fn description(&self) -> &str;
 }
 
+/// Loads DLF credentials from a local JSON file.
+pub struct DLFLocalFileTokenLoader {
+    token_file_path: String,
+}
+
+impl DLFLocalFileTokenLoader {
+    const DEFAULT_MAX_RETRIES: u32 = 5;
+
+    /// Create a local-file token loader.
+    pub fn new(token_file_path: impl Into<String>) -> Result<Self> {
+        let token_file_path = token_file_path.into();
+        if token_file_path.trim().is_empty() {
+            return Err(Error::ConfigInvalid {
+                message: "dlf.token-path must not be empty".to_string(),
+            });
+        }
+        Ok(Self { token_file_path })
+    }
+
+    async fn read_token(&self, max_retries: u32) -> Result<DLFToken> {
+        let mut last_error = None;
+        for attempt in 0..max_retries.max(1) {
+            let result = match 
tokio::fs::read_to_string(&self.token_file_path).await {
+                Ok(token_json) => DLFToken::from_json(&token_json),
+                Err(error) => Err(Error::DataInvalid {
+                    message: format!("Failed to read DLF token file: {}", 
self.token_file_path),
+                    source: Some(Box::new(error)),
+                }),
+            };
+            match result {
+                Ok(token) => return Ok(token),
+                Err(error) => last_error = Some(error),
+            }
+            if attempt + 1 < max_retries {
+                
tokio::time::sleep(std::time::Duration::from_secs(u64::from(attempt + 
1))).await;
+            }
+        }
+        Err(last_error.expect("at least one token load attempt"))
+    }
+}
+
+#[async_trait]
+impl DLFTokenLoader for DLFLocalFileTokenLoader {
+    async fn load_token(&self) -> Result<DLFToken> {
+        self.read_token(Self::DEFAULT_MAX_RETRIES).await
+    }
+
+    fn description(&self) -> &str {
+        &self.token_file_path
+    }
+}
+
 /// DLF ECS Token Loader.
 ///
 /// Loads DLF tokens from ECS metadata service.
@@ -167,24 +242,7 @@ impl DLFECSTokenLoader {
     /// Get the token from ECS metadata service.
     async fn get_token(&self, url: &str) -> Result<DLFToken> {
         let token_json = self.http_client.get(url).await?;
-        let mut token: DLFToken =
-            serde_json::from_str(&token_json).map_err(|e| Error::DataInvalid {
-                message: format!("Failed to parse token JSON: {e}"),
-                source: None,
-            })?;
-        if token.expiration_at_millis.is_none() {
-            if let Some(expiration) = token.expiration.as_deref() {
-                token.expiration_at_millis = Some(
-                    
DLFToken::parse_expiration_to_millis(expiration).ok_or_else(|| {
-                        Error::DataInvalid {
-                            message: format!("Failed to parse token 
Expiration: {expiration}"),
-                            source: None,
-                        }
-                    })?,
-                );
-            }
-        }
-        Ok(token)
+        DLFToken::from_json(&token_json)
     }
 
     /// Build the token URL from base URL and role name.
@@ -221,27 +279,47 @@ pub struct DLFTokenLoaderFactory;
 
 impl DLFTokenLoaderFactory {
     /// Create a token loader based on options.
-    pub fn create_token_loader(options: &Options) -> Option<Arc<dyn 
DLFTokenLoader>> {
-        let loader = options.get(CatalogOptions::DLF_TOKEN_LOADER)?;
-
-        if loader == "ecs" {
-            let ecs_metadata_url = options
-                .get(CatalogOptions::DLF_TOKEN_ECS_METADATA_URL)
-                .cloned()
-                .unwrap_or_else(|| {
-                    
"http://100.100.100.200/latest/meta-data/Ram/security-credentials/".to_string()
-                });
-            let role_name = options
-                .get(CatalogOptions::DLF_TOKEN_ECS_ROLE_NAME)
-                .cloned();
-            Some(
-                Arc::new(DLFECSTokenLoader::new(ecs_metadata_url, role_name))
-                    as Arc<dyn DLFTokenLoader>,
-            )
-        } else {
-            None
+    pub fn create_token_loader(options: &Options) -> Result<Option<Arc<dyn 
DLFTokenLoader>>> {
+        match options
+            .get(CatalogOptions::DLF_TOKEN_LOADER)
+            .map(String::as_str)
+        {
+            Some("ecs") => {
+                let ecs_metadata_url = options
+                    .get(CatalogOptions::DLF_TOKEN_ECS_METADATA_URL)
+                    .cloned()
+                    .unwrap_or_else(|| {
+                        
"http://100.100.100.200/latest/meta-data/Ram/security-credentials/";
+                            .to_string()
+                    });
+                let role_name = options
+                    .get(CatalogOptions::DLF_TOKEN_ECS_ROLE_NAME)
+                    .cloned();
+                Ok(Some(Arc::new(DLFECSTokenLoader::new(
+                    ecs_metadata_url,
+                    role_name,
+                ))))
+            }
+            Some("local_file") => 
Self::create_local_file_loader(options).map(Some),
+            Some(loader) => Err(Error::ConfigInvalid {
+                message: format!("Unknown DLF token loader: {loader}"),
+            }),
+            None if options.get(CatalogOptions::DLF_TOKEN_PATH).is_some() => {
+                Self::create_local_file_loader(options).map(Some)
+            }
+            None => Ok(None),
         }
     }
+
+    fn create_local_file_loader(options: &Options) -> Result<Arc<dyn 
DLFTokenLoader>> {
+        let token_path =
+            options
+                .get(CatalogOptions::DLF_TOKEN_PATH)
+                .ok_or_else(|| Error::ConfigInvalid {
+                    message: "dlf.token-path is required for local_file token 
loading".to_string(),
+                })?;
+        Ok(Arc::new(DLFLocalFileTokenLoader::new(token_path)?))
+    }
 }
 // ============================================================================
 // DLF Auth Provider
@@ -488,6 +566,206 @@ mod tests {
         assert!(millis.is_some());
     }
 
+    #[tokio::test]
+    async fn test_local_file_token_loader_formats() {
+        let dir = tempfile::tempdir().unwrap();
+        let path = dir.path().join("token.json");
+        let loader = 
DLFLocalFileTokenLoader::new(path.display().to_string()).unwrap();
+
+        tokio::fs::write(
+            &path,
+            
r#"{"AccessKeyId":"ak-1","AccessKeySecret":"sk-1","SecurityToken":"sts-1"}"#,
+        )
+        .await
+        .unwrap();
+        let token = loader.read_token(1).await.unwrap();
+        assert_eq!(token.access_key_id, "ak-1");
+        assert_eq!(token.security_token.as_deref(), Some("sts-1"));
+        assert_eq!(token.expiration_at_millis, None);
+
+        tokio::fs::write(
+            &path,
+            
r#"{"AccessKeyId":"ak-2","AccessKeySecret":"sk-2","ExpirationAt":123}"#,
+        )
+        .await
+        .unwrap();
+        let token = loader.read_token(1).await.unwrap();
+        assert_eq!(token.security_token, None);
+        assert_eq!(token.expiration_at_millis, None);
+
+        tokio::fs::write(
+            &path,
+            
r#"{"AccessKeyId":"ak-3","AccessKeySecret":"sk-3","Expiration":"2099-01-01T00:00:00Z"}"#,
+        )
+        .await
+        .unwrap();
+        let token = loader.read_token(1).await.unwrap();
+        assert_eq!(
+            token.expiration_at_millis,
+            DLFToken::parse_expiration_to_millis("2099-01-01T00:00:00Z")
+        );
+
+        tokio::fs::write(
+            &path,
+            
r#"{"AccessKeyId":"ak-4","AccessKeySecret":"sk-4","ExpirationAt":123,"Expiration":"2099-01-01T00:00:00Z"}"#,
+        )
+        .await
+        .unwrap();
+        let token = loader.read_token(1).await.unwrap();
+        assert_eq!(
+            token.expiration_at_millis,
+            DLFToken::parse_expiration_to_millis("2099-01-01T00:00:00Z")
+        );
+    }
+
+    #[tokio::test]
+    async fn test_local_file_token_loader_errors_do_not_leak_credentials() {
+        let dir = tempfile::tempdir().unwrap();
+        let path = dir.path().join("token.json");
+        let loader = 
DLFLocalFileTokenLoader::new(path.display().to_string()).unwrap();
+
+        let error = loader.read_token(1).await.unwrap_err();
+        assert!(error.to_string().contains("Failed to read DLF token file"));
+
+        let secret = "SECRET_MUST_NOT_LEAK";
+        tokio::fs::write(
+            &path,
+            format!(r#"{{"AccessKeyId":"ak","AccessKeySecret":"{secret}" 
invalid}}"#),
+        )
+        .await
+        .unwrap();
+        let error = loader.read_token(1).await.unwrap_err();
+        assert!(error.to_string().contains("Failed to parse token JSON"));
+        assert!(!error.to_string().contains(secret));
+
+        tokio::fs::write(&path, r#"{"AccessKeyId":"ak"}"#)
+            .await
+            .unwrap();
+        let error = loader.read_token(1).await.unwrap_err();
+        assert!(error.to_string().contains("Failed to parse token JSON"));
+
+        tokio::fs::write(
+            &path,
+            
r#"{"AccessKeyId":"","AccessKeySecret":"sk","Expiration":"invalid"}"#,
+        )
+        .await
+        .unwrap();
+        let error = loader.read_token(1).await.unwrap_err();
+        assert!(error.to_string().contains("must not be empty"));
+
+        tokio::fs::write(
+            &path,
+            
r#"{"AccessKeyId":"ak","AccessKeySecret":"sk","Expiration":"invalid"}"#,
+        )
+        .await
+        .unwrap();
+        let error = loader.read_token(1).await.unwrap_err();
+        assert!(error
+            .to_string()
+            .contains("Failed to parse token Expiration"));
+
+        tokio::fs::write(
+            &path,
+            
r#"{"AccessKeyId":"ak","AccessKeySecret":"sk","ExpirationAt":123,"Expiration":"invalid"}"#,
+        )
+        .await
+        .unwrap();
+        let error = loader.read_token(1).await.unwrap_err();
+        assert!(error
+            .to_string()
+            .contains("Failed to parse token Expiration"));
+    }
+
+    #[test]
+    fn test_local_file_token_loader_factory_validation() {
+        let mut options = Options::new();
+        options.set(CatalogOptions::DLF_TOKEN_LOADER, "local_file");
+        assert!(DLFTokenLoaderFactory::create_token_loader(&options)
+            .err()
+            .unwrap()
+            .to_string()
+            .contains("dlf.token-path is required"));
+
+        options.set(CatalogOptions::DLF_TOKEN_PATH, " ");
+        assert!(DLFTokenLoaderFactory::create_token_loader(&options)
+            .err()
+            .unwrap()
+            .to_string()
+            .contains("must not be empty"));
+
+        options.set(CatalogOptions::DLF_TOKEN_LOADER, "unknown");
+        assert!(DLFTokenLoaderFactory::create_token_loader(&options)
+            .err()
+            .unwrap()
+            .to_string()
+            .contains("Unknown DLF token loader"));
+
+        options.remove(CatalogOptions::DLF_TOKEN_LOADER);
+        options.set(CatalogOptions::DLF_TOKEN_PATH, "/tmp/token.json");
+        assert_eq!(
+            DLFTokenLoaderFactory::create_token_loader(&options)
+                .unwrap()
+                .unwrap()
+                .description(),
+            "/tmp/token.json"
+        );
+    }
+
+    #[tokio::test]
+    async fn test_local_file_provider_refreshes_expiring_token() {
+        let dir = tempfile::tempdir().unwrap();
+        let path = dir.path().join("token.json");
+        let expires_soon = (Utc::now()
+            + chrono::Duration::milliseconds(TOKEN_EXPIRATION_SAFE_TIME_MILLIS 
/ 2))
+        .format(DLFToken::TOKEN_DATE_FORMAT);
+        tokio::fs::write(
+            &path,
+            format!(
+                
r#"{{"AccessKeyId":"old-ak","AccessKeySecret":"old-sk","Expiration":"{expires_soon}"}}"#
+            ),
+        )
+        .await
+        .unwrap();
+        let loader = 
Arc::new(DLFLocalFileTokenLoader::new(path.display().to_string()).unwrap());
+        let provider = DLFAuthProvider::new(
+            "https://dlf.cn-hangzhou.aliyuncs.com";,
+            "cn-hangzhou",
+            "default",
+            None,
+            Some(loader),
+        )
+        .unwrap();
+
+        let auth = RESTAuthParameter::new("GET", "/test", None, 
HashMap::new());
+        let headers = provider
+            .merge_auth_header(HashMap::new(), &auth)
+            .await
+            .unwrap();
+        assert!(headers[AUTHORIZATION_HEADER_KEY].contains("old-ak"));
+        let expires_later = (Utc::now()
+            + chrono::Duration::milliseconds(TOKEN_EXPIRATION_SAFE_TIME_MILLIS 
* 2))
+        .format(DLFToken::TOKEN_DATE_FORMAT);
+        tokio::fs::write(
+            &path,
+            format!(
+                
r#"{{"AccessKeyId":"new-ak","AccessKeySecret":"new-sk","Expiration":"{expires_later}"}}"#
+            ),
+        )
+        .await
+        .unwrap();
+        let headers = provider
+            .merge_auth_header(HashMap::new(), &auth)
+            .await
+            .unwrap();
+        assert!(headers[AUTHORIZATION_HEADER_KEY].contains("new-ak"));
+        tokio::fs::remove_file(path).await.unwrap();
+        let headers = provider
+            .merge_auth_header(HashMap::new(), &auth)
+            .await
+            .unwrap();
+        assert!(headers[AUTHORIZATION_HEADER_KEY].contains("new-ak"));
+    }
+
     struct RotatingTokenLoader {
         requests: std::sync::atomic::AtomicUsize,
     }
diff --git a/crates/paimon/src/api/auth/factory.rs 
b/crates/paimon/src/api/auth/factory.rs
index 09c3070a..06df12e0 100644
--- a/crates/paimon/src/api/auth/factory.rs
+++ b/crates/paimon/src/api/auth/factory.rs
@@ -95,13 +95,13 @@ impl DLFAuthProviderFactory {
             .unwrap_or_else(|| Self::parse_signing_algo_from_uri(Some(&uri)))
             .to_string();
 
-        let dlf_provider = DLFAuthProvider::new(
-            uri,
-            region,
-            signing_algorithm,
-            DLFToken::from_options(options),
-            DLFTokenLoaderFactory::create_token_loader(options),
-        )?;
+        let token_loader = 
DLFTokenLoaderFactory::create_token_loader(options)?;
+        let token = token_loader
+            .is_none()
+            .then(|| DLFToken::from_options(options))
+            .flatten();
+        let dlf_provider =
+            DLFAuthProvider::new(uri, region, signing_algorithm, token, 
token_loader)?;
 
         Ok(Box::new(dlf_provider))
     }
diff --git a/crates/paimon/src/api/auth/mod.rs 
b/crates/paimon/src/api/auth/mod.rs
index 7afb9d86..816c3623 100644
--- a/crates/paimon/src/api/auth/mod.rs
+++ b/crates/paimon/src/api/auth/mod.rs
@@ -25,5 +25,7 @@ mod factory;
 
 pub use base::{AuthProvider, RESTAuthFunction, RESTAuthParameter};
 pub use bearer_provider::BearerTokenAuthProvider;
-pub use dlf_provider::{DLFAuthProvider, DLFECSTokenLoader, DLFToken, 
DLFTokenLoader};
+pub use dlf_provider::{
+    DLFAuthProvider, DLFECSTokenLoader, DLFLocalFileTokenLoader, DLFToken, 
DLFTokenLoader,
+};
 pub use factory::{AuthProviderFactory, DLFAuthProviderFactory};
diff --git a/crates/paimon/src/common/options.rs 
b/crates/paimon/src/common/options.rs
index c23cd931..5bb8c121 100644
--- a/crates/paimon/src/common/options.rs
+++ b/crates/paimon/src/common/options.rs
@@ -61,9 +61,12 @@ impl CatalogOptions {
     /// DLF signing algorithm (default or openapi).
     pub const DLF_SIGNING_ALGORITHM: &'static str = "dlf.signing-algorithm";
 
-    /// DLF token loader type (e.g., "ecs").
+    /// DLF token loader type ("ecs" or "local_file").
     pub const DLF_TOKEN_LOADER: &'static str = "dlf.token-loader";
 
+    /// Path to a local DLF token file.
+    pub const DLF_TOKEN_PATH: &'static str = "dlf.token-path";
+
     /// DLF ECS metadata URL.
     pub const DLF_TOKEN_ECS_METADATA_URL: &'static str = 
"dlf.token-ecs-metadata-url";
 
diff --git a/crates/paimon/tests/rest_catalog_test.rs 
b/crates/paimon/tests/rest_catalog_test.rs
index e2dd53ec..004d435e 100644
--- a/crates/paimon/tests/rest_catalog_test.rs
+++ b/crates/paimon/tests/rest_catalog_test.rs
@@ -1059,7 +1059,7 @@ async fn 
test_blob_view_limit_only_resolves_selected_references() {
 
 #[cfg(not(windows))]
 #[tokio::test]
-async fn test_rest_catalog_reads_format_table() {
+async fn 
test_rest_catalog_reads_format_table_with_bearer_and_local_file_dlf_auth() {
     use parquet::arrow::ArrowWriter;
     use std::fs::File;
 
@@ -1144,6 +1144,40 @@ async fn test_rest_catalog_reads_format_table() {
             .sum::<usize>(),
         1
     );
+
+    let token_path = tmp.path().join("dlf-token.json");
+    tokio::fs::write(
+        &token_path,
+        
r#"{"AccessKeyId":"test-ak","AccessKeySecret":"test-sk","SecurityToken":"test-sts"}"#,
+    )
+    .await
+    .unwrap();
+    let mut dlf_options = Options::new();
+    dlf_options.set(CatalogOptions::URI, ctx.server.url().unwrap());
+    dlf_options.set(CatalogOptions::WAREHOUSE, "test_warehouse");
+    dlf_options.set(CatalogOptions::TOKEN_PROVIDER, "dlf");
+    dlf_options.set(CatalogOptions::DLF_REGION, "cn-hangzhou");
+    dlf_options.set(CatalogOptions::DLF_TOKEN_LOADER, "local_file");
+    dlf_options.set(
+        CatalogOptions::DLF_TOKEN_PATH,
+        token_path.display().to_string(),
+    );
+    let dlf_catalog = RESTCatalog::new(dlf_options, true).await.unwrap();
+    let dlf_table = dlf_catalog.get_table(&identifier).await.unwrap();
+    let dlf_builder = dlf_table.new_read_builder();
+    let dlf_plan = dlf_builder.new_scan().plan().await.unwrap();
+    let dlf_batches = dlf_builder
+        .new_read()
+        .unwrap()
+        .to_arrow(dlf_plan.splits())
+        .unwrap()
+        .try_collect::<Vec<_>>()
+        .await
+        .unwrap();
+    assert_eq!(
+        dlf_batches.iter().map(RecordBatch::num_rows).sum::<usize>(),
+        2
+    );
 }
 
 #[tokio::test]
diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md
index de526dff..0a82fb3b 100644
--- a/docs/src/getting-started.md
+++ b/docs/src/getting-started.md
@@ -292,6 +292,16 @@ refreshes those credentials before expiration, so callers 
do not need to load
 the table again. Static `dlf.access-key-id`, `dlf.access-key-secret`, and
 `dlf.security-token` values are not rotated.
 
+To read credentials managed by another process, use a local token file:
+
+```rust
+options.set(CatalogOptions::DLF_TOKEN_LOADER, "local_file");
+options.set(CatalogOptions::DLF_TOKEN_PATH, "/path/to/token.json");
+```
+
+The JSON uses `AccessKeyId`, `AccessKeySecret`, optional `SecurityToken`, and
+optional `Expiration`. Expiring credentials are reloaded before expiration.
+
 Supported metastore types:
 
 | Metastore Type | Description                      |

Reply via email to