You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@opendal.apache.org by xu...@apache.org on 2026/03/18 20:25:03 UTC
(opendal-reqsign) branch main updated: feat(aliyun-oss): add credentials URI and ECS RAM role providers (#721)
This is an automated email from the ASF dual-hosted git repository.
xuanwo pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/opendal-reqsign.git
The following commit(s) were added to refs/heads/main by this push:
new 85ed1dd feat(aliyun-oss): add credentials URI and ECS RAM role providers (#721)
85ed1dd is described below
commit 85ed1dd30680682789f0869fcc7ec40d93b49bbe
Author: Xuanwo <gi...@xuanwo.io>
AuthorDate: Thu Mar 19 04:24:58 2026 +0800
feat(aliyun-oss): add credentials URI and ECS RAM role providers (#721)
This stacked PR builds on #720 by adding the two network-based runtime
credential sources needed for Aliyun OSS parity: `credentials_uri` and
ECS RAM role metadata.
It introduces dedicated slot/no-slot builder entries, keeps refresh
behavior aligned with `reqsign_core::Signer` cache semantics, and adds
focused tests for chain order, IMDS fallback, and refresh-before-expiry
behavior. Related to #684.
---
services/aliyun-oss/README.md | 43 +-
services/aliyun-oss/examples/oss_operations.rs | 14 +-
services/aliyun-oss/src/constants.rs | 6 +
services/aliyun-oss/src/lib.rs | 24 +-
.../src/provide_credential/credentials_uri.rs | 411 ++++++++++++++
.../aliyun-oss/src/provide_credential/default.rs | 338 ++++++++++-
.../src/provide_credential/ecs_ram_role.rs | 618 +++++++++++++++++++++
services/aliyun-oss/src/provide_credential/mod.rs | 6 +
8 files changed, 1424 insertions(+), 36 deletions(-)
diff --git a/services/aliyun-oss/README.md b/services/aliyun-oss/README.md
index 27582ff..10aefc9 100644
--- a/services/aliyun-oss/README.md
+++ b/services/aliyun-oss/README.md
@@ -11,9 +11,10 @@ This crate provides signing support for Alibaba Cloud Object Storage Service (OS
```rust
use reqsign_aliyun_oss::{
AssumeRoleCredentialProvider, AssumeRoleWithOidcCredentialProvider,
- ConfigFileCredentialProvider, CredentialsFileCredentialProvider, DefaultCredentialProvider,
- EnvCredentialProvider, OssProfileCredentialProvider, RequestSigner,
- SigningVersion, StaticCredentialProvider,
+ ConfigFileCredentialProvider, CredentialsFileCredentialProvider,
+ CredentialsUriCredentialProvider, DefaultCredentialProvider,
+ EcsRamRoleCredentialProvider, EnvCredentialProvider, OssProfileCredentialProvider,
+ RequestSigner, SigningVersion, StaticCredentialProvider,
};
use reqsign_core::{Context, Result, Signer};
use reqsign_file_read_tokio::TokioFileRead;
@@ -31,6 +32,8 @@ async fn main() -> Result<()> {
.oss_profile(OssProfileCredentialProvider::new())
.credentials_file(CredentialsFileCredentialProvider::new())
.config_file(ConfigFileCredentialProvider::new())
+ .credentials_uri(CredentialsUriCredentialProvider::new())
+ .ecs_ram_role(EcsRamRoleCredentialProvider::new())
.oidc(AssumeRoleWithOidcCredentialProvider::new())
.build();
@@ -65,6 +68,7 @@ async fn main() -> Result<()> {
- **Multiple Credential Sources**: Environment variables, OSS profile files, Alibaba shared credential/config files, AssumeRole, and OIDC-based STS exchange
- **V1 and V4 Signing**: Supports both legacy OSS V1 signatures and Signature V4
+- **Runtime Credential Sources**: Credentials URI, ECS RAM role metadata, and OIDC-based STS exchange
- **STS Support**: Temporary credentials via Security Token Service
- **All OSS Operations**: Object, bucket, and multipart operations
@@ -114,6 +118,28 @@ access_key_secret = prod-access-key-secret
Override the file path with `OSS_CREDENTIAL_PROFILES_FILE` and the selected profile with `OSS_PROFILE`.
+### Credentials URI
+
+Load temporary credentials from a custom endpoint:
+
+```bash
+export ALIBABA_CLOUD_CREDENTIALS_URI=http://127.0.0.1:8080/credentials
+```
+
+The endpoint should return JSON containing `AccessKeyId`, `AccessKeySecret`, `SecurityToken`, and `Expiration`.
+
+### ECS RAM Role Metadata
+
+On Alibaba Cloud ECS instances, the provider reads temporary credentials from the metadata service.
+
+```bash
+export ALIBABA_CLOUD_ECS_METADATA=my-ram-role # Optional role name override
+export ALIBABA_CLOUD_IMDSV1_DISABLED=true # Optional: require IMDSv2
+export ALIBABA_CLOUD_ECS_METADATA_SERVICE_ENDPOINT=http://127.0.0.1 # Optional override for tests
+```
+
+If `ALIBABA_CLOUD_ECS_METADATA` is unset, the provider resolves the role name from metadata first and then fetches the credentials.
+
### Alibaba Shared Credentials File
Reads from `~/.alibabacloud/credentials.ini` first and falls back to `~/.aliyun/credentials.ini`:
@@ -158,6 +184,7 @@ Reads from `~/.aliyun/config.json` by default:
Override the file path with `ALIBABA_CLOUD_CONFIG_FILE` and the selected profile with `ALIBABA_CLOUD_PROFILE`.
Only direct static modes are loaded in this crate today: `AK` and `StsToken`.
+
### STS AssumeRole with OIDC
For Kubernetes/ACK environments:
@@ -170,6 +197,8 @@ use reqsign_aliyun_oss::{AssumeRoleWithOidcCredentialProvider, DefaultCredential
let loader = DefaultCredentialProvider::builder()
.no_env()
.no_oss_profile()
+ .no_credentials_uri()
+ .no_ecs_ram_role()
.no_credentials_file()
.no_config_file()
.oidc(
@@ -331,7 +360,13 @@ let loader = StaticCredentialProvider::new("your-access-key-id", "your-access-ke
```rust
use reqsign_aliyun_oss::DefaultCredentialProvider;
-let loader = DefaultCredentialProvider::builder().no_oidc().build();
+let loader = DefaultCredentialProvider::builder()
+ .no_credentials_file()
+ .no_config_file()
+ .no_credentials_uri()
+ .no_ecs_ram_role()
+ .no_oidc()
+ .build();
```
## License
diff --git a/services/aliyun-oss/examples/oss_operations.rs b/services/aliyun-oss/examples/oss_operations.rs
index 09b5c6c..68a4861 100644
--- a/services/aliyun-oss/examples/oss_operations.rs
+++ b/services/aliyun-oss/examples/oss_operations.rs
@@ -16,8 +16,10 @@
// under the License.
use reqsign_aliyun_oss::{
- AssumeRoleCredentialProvider, AssumeRoleWithOidcCredentialProvider, DefaultCredentialProvider,
- EnvCredentialProvider, RequestSigner, StaticCredentialProvider,
+ AssumeRoleCredentialProvider, AssumeRoleWithOidcCredentialProvider,
+ ConfigFileCredentialProvider, CredentialsFileCredentialProvider,
+ CredentialsUriCredentialProvider, DefaultCredentialProvider, EcsRamRoleCredentialProvider,
+ EnvCredentialProvider, OssProfileCredentialProvider, RequestSigner, StaticCredentialProvider,
};
use reqsign_core::Result;
use reqsign_core::{Context, OsEnv, Signer};
@@ -62,10 +64,16 @@ async fn main() -> Result<()> {
StaticCredentialProvider::new("LTAI4GDemoAccessKeyId", "DemoAccessKeySecretForExample");
Signer::new(ctx.clone(), loader, builder)
} else {
- // Build the default assume_role -> env -> oidc chain explicitly via slot APIs.
+ // Build the default assume_role -> env -> oss_profile -> credentials_file ->
+ // config_file -> credentials_uri -> ecs_ram_role -> oidc chain.
let loader = DefaultCredentialProvider::builder()
.assume_role(AssumeRoleCredentialProvider::new())
.env(EnvCredentialProvider::new())
+ .oss_profile(OssProfileCredentialProvider::new())
+ .credentials_file(CredentialsFileCredentialProvider::new())
+ .config_file(ConfigFileCredentialProvider::new())
+ .credentials_uri(CredentialsUriCredentialProvider::new())
+ .ecs_ram_role(EcsRamRoleCredentialProvider::new())
.oidc(AssumeRoleWithOidcCredentialProvider::new())
.build();
Signer::new(ctx.clone(), loader, builder)
diff --git a/services/aliyun-oss/src/constants.rs b/services/aliyun-oss/src/constants.rs
index ca20192..590f11d 100644
--- a/services/aliyun-oss/src/constants.rs
+++ b/services/aliyun-oss/src/constants.rs
@@ -27,6 +27,12 @@ pub const OSS_ACCESS_KEY_SECRET: &str = "OSS_ACCESS_KEY_SECRET";
pub const OSS_SESSION_TOKEN: &str = "OSS_SESSION_TOKEN";
pub const OSS_PROFILE: &str = "OSS_PROFILE";
pub const OSS_CREDENTIAL_PROFILES_FILE: &str = "OSS_CREDENTIAL_PROFILES_FILE";
+pub const ALIBABA_CLOUD_CREDENTIALS_URI: &str = "ALIBABA_CLOUD_CREDENTIALS_URI";
+pub const ALIBABA_CLOUD_ECS_METADATA: &str = "ALIBABA_CLOUD_ECS_METADATA";
+pub const ALIBABA_CLOUD_ECS_METADATA_DISABLED: &str = "ALIBABA_CLOUD_ECS_METADATA_DISABLED";
+pub const ALIBABA_CLOUD_ECS_METADATA_SERVICE_ENDPOINT: &str =
+ "ALIBABA_CLOUD_ECS_METADATA_SERVICE_ENDPOINT";
+pub const ALIBABA_CLOUD_IMDSV1_DISABLED: &str = "ALIBABA_CLOUD_IMDSV1_DISABLED";
pub const ALIBABA_CLOUD_ROLE_ARN: &str = "ALIBABA_CLOUD_ROLE_ARN";
pub const ALIBABA_CLOUD_ROLE_SESSION_NAME: &str = "ALIBABA_CLOUD_ROLE_SESSION_NAME";
pub const ALIBABA_CLOUD_EXTERNAL_ID: &str = "ALIBABA_CLOUD_EXTERNAL_ID";
diff --git a/services/aliyun-oss/src/lib.rs b/services/aliyun-oss/src/lib.rs
index b10a938..39c4f9d 100644
--- a/services/aliyun-oss/src/lib.rs
+++ b/services/aliyun-oss/src/lib.rs
@@ -34,9 +34,10 @@
//! ```no_run
//! use reqsign_aliyun_oss::{
//! AssumeRoleCredentialProvider, AssumeRoleWithOidcCredentialProvider,
-//! ConfigFileCredentialProvider, CredentialsFileCredentialProvider, DefaultCredentialProvider,
-//! EnvCredentialProvider, OssProfileCredentialProvider, RequestSigner,
-//! SigningVersion, StaticCredentialProvider,
+//! ConfigFileCredentialProvider, CredentialsFileCredentialProvider,
+//! CredentialsUriCredentialProvider, DefaultCredentialProvider,
+//! EcsRamRoleCredentialProvider, EnvCredentialProvider, OssProfileCredentialProvider,
+//! RequestSigner, SigningVersion, StaticCredentialProvider,
//! };
//! use reqsign_core::{Context, Signer, Result};
//! use reqsign_file_read_tokio::TokioFileRead;
@@ -50,13 +51,16 @@
//! .with_http_send(ReqwestHttpSend::default());
//!
//! // Create credential loader with the default assume_role -> env ->
-//! // OSS profile -> shared credentials file -> config file -> oidc chain.
+//! // OSS profile -> shared credentials file -> config file ->
+//! // credentials URI -> ECS RAM role -> oidc chain.
//! let loader = DefaultCredentialProvider::builder()
//! .assume_role(AssumeRoleCredentialProvider::new())
//! .env(EnvCredentialProvider::new())
//! .oss_profile(OssProfileCredentialProvider::new())
//! .credentials_file(CredentialsFileCredentialProvider::new())
//! .config_file(ConfigFileCredentialProvider::new())
+//! .credentials_uri(CredentialsUriCredentialProvider::new())
+//! .ecs_ram_role(EcsRamRoleCredentialProvider::new())
//! .oidc(AssumeRoleWithOidcCredentialProvider::new())
//! .build();
//!
@@ -106,6 +110,15 @@
//! The crate can load credentials from the OSS profile file
//! (typically `~/.oss/credentials`).
//!
+//! ### Credentials URI
+//!
+//! The crate can load temporary credentials from
+//! `ALIBABA_CLOUD_CREDENTIALS_URI`.
+//!
+//! ### ECS RAM Role
+//!
+//! The crate can load temporary credentials from the ECS metadata service.
+//!
//! ### Alibaba Shared Credential and Config Files
//!
//! The crate can also load static credentials from Alibaba shared SDK files
@@ -163,6 +176,8 @@
//! let loader = DefaultCredentialProvider::builder()
//! .no_env()
//! .no_oss_profile()
+//! .no_credentials_uri()
+//! .no_ecs_ram_role()
//! .no_credentials_file()
//! .no_config_file()
//! .assume_role(
@@ -201,7 +216,6 @@
//!
//! Check out the examples directory:
//! - [Basic OSS operations](examples/oss_operations.rs)
-//! - [STS authentication](examples/sts_auth.rs)
mod constants;
diff --git a/services/aliyun-oss/src/provide_credential/credentials_uri.rs b/services/aliyun-oss/src/provide_credential/credentials_uri.rs
new file mode 100644
index 0000000..304d6e3
--- /dev/null
+++ b/services/aliyun-oss/src/provide_credential/credentials_uri.rs
@@ -0,0 +1,411 @@
+// 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.
+
+use crate::{Credential, constants::ALIBABA_CLOUD_CREDENTIALS_URI};
+use reqsign_core::time::Timestamp;
+use reqsign_core::{Context, Error, ProvideCredential, Result};
+use serde::Deserialize;
+
+/// CredentialsUriCredentialProvider loads temporary credentials from a custom URI.
+///
+/// Configuration values can be provided directly via [`with_uri`] or through the
+/// `ALIBABA_CLOUD_CREDENTIALS_URI` environment variable.
+#[derive(Debug, Default, Clone)]
+pub struct CredentialsUriCredentialProvider {
+ uri: Option<String>,
+}
+
+impl CredentialsUriCredentialProvider {
+ /// Create a new `CredentialsUriCredentialProvider` instance.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Set the credentials URI directly.
+ ///
+ /// This setting takes precedence over `ALIBABA_CLOUD_CREDENTIALS_URI`.
+ pub fn with_uri(mut self, uri: impl Into<String>) -> Self {
+ self.uri = Some(uri.into());
+ self
+ }
+
+ fn get_uri(&self, ctx: &Context) -> Option<String> {
+ self.uri
+ .clone()
+ .or_else(|| ctx.env_var(ALIBABA_CLOUD_CREDENTIALS_URI))
+ .filter(|uri| !uri.is_empty())
+ }
+}
+
+impl ProvideCredential for CredentialsUriCredentialProvider {
+ type Credential = Credential;
+
+ async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
+ let Some(uri) = self.get_uri(ctx) else {
+ return Ok(None);
+ };
+
+ let req = http::Request::builder()
+ .method(http::Method::GET)
+ .uri(&uri)
+ .body(Vec::new())
+ .map_err(|e| {
+ Error::request_invalid("failed to build credentials URI request")
+ .with_source(e)
+ .with_context(format!("uri: {uri}"))
+ })?;
+
+ let resp = ctx
+ .http_send_as_string(req.map(Into::into))
+ .await
+ .map_err(|e| {
+ Error::unexpected("failed to fetch credentials from credentials URI")
+ .with_source(e)
+ .with_context(format!("uri: {uri}"))
+ .set_retryable(true)
+ })?;
+
+ let status = resp.status();
+ let body = resp.into_body();
+
+ if status != http::StatusCode::OK {
+ let error = match status.as_u16() {
+ 401 | 403 => {
+ Error::permission_denied(format!("credentials URI returned {status}: {body}"))
+ }
+ 404 => Error::config_invalid("credentials URI not found")
+ .with_context(format!("uri: {uri}")),
+ 500..=599 => {
+ Error::unexpected(format!("credentials URI returned {status}: {body}"))
+ .set_retryable(true)
+ }
+ _ => Error::unexpected(format!("credentials URI returned {status}: {body}")),
+ };
+
+ return Err(error.with_context(format!("uri: {uri}")));
+ }
+
+ let resp: CredentialsUriResponse = serde_json::from_str(&body).map_err(|e| {
+ Error::unexpected("failed to parse credentials URI response")
+ .with_source(e)
+ .with_context(format!("uri: {uri}"))
+ })?;
+
+ if !resp.code.is_empty() && resp.code != "Success" {
+ return Err(Error::unexpected(format!(
+ "credentials URI returned error code {}",
+ resp.code
+ ))
+ .with_context(format!("uri: {uri}")));
+ }
+
+ if resp.access_key_id.is_empty()
+ || resp.access_key_secret.is_empty()
+ || resp.security_token.is_empty()
+ || resp.expiration.is_empty()
+ {
+ return Err(Error::credential_invalid(
+ "credentials URI response is missing required fields",
+ )
+ .with_context(format!("uri: {uri}")));
+ }
+
+ let expires_in: Timestamp = resp.expiration.parse().map_err(|e| {
+ Error::credential_invalid("failed to parse credentials URI expiration")
+ .with_source(e)
+ .with_context(format!("uri: {uri}"))
+ })?;
+
+ Ok(Some(Credential {
+ access_key_id: resp.access_key_id,
+ access_key_secret: resp.access_key_secret,
+ security_token: Some(resp.security_token),
+ expires_in: Some(expires_in),
+ }))
+ }
+}
+
+#[derive(Default, Debug, Deserialize)]
+#[serde(default, rename_all = "PascalCase")]
+struct CredentialsUriResponse {
+ code: String,
+ access_key_id: String,
+ access_key_secret: String,
+ #[serde(alias = "SessionToken")]
+ security_token: String,
+ expiration: String,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use bytes::Bytes;
+ use reqsign_core::time::Timestamp;
+ use reqsign_core::{Context, HttpSend, StaticEnv};
+ use reqsign_file_read_tokio::TokioFileRead;
+ use std::collections::{HashMap, VecDeque};
+ use std::sync::{Arc, Mutex};
+ use std::time::Duration;
+
+ #[derive(Debug, Clone)]
+ struct MockResponse {
+ status: http::StatusCode,
+ body: String,
+ }
+
+ #[derive(Debug, Clone, PartialEq, Eq)]
+ struct CapturedRequest {
+ method: http::Method,
+ uri: String,
+ }
+
+ #[derive(Debug, Clone)]
+ struct SequenceHttpSend {
+ requests: Arc<Mutex<Vec<CapturedRequest>>>,
+ responses: Arc<Mutex<VecDeque<MockResponse>>>,
+ }
+
+ impl SequenceHttpSend {
+ fn new(responses: Vec<MockResponse>) -> Self {
+ Self {
+ requests: Arc::new(Mutex::new(Vec::new())),
+ responses: Arc::new(Mutex::new(responses.into())),
+ }
+ }
+
+ fn requests(&self) -> Vec<CapturedRequest> {
+ self.requests.lock().expect("lock poisoned").clone()
+ }
+
+ fn call_count(&self) -> usize {
+ self.requests.lock().expect("lock poisoned").len()
+ }
+ }
+
+ impl HttpSend for SequenceHttpSend {
+ async fn http_send(
+ &self,
+ req: http::Request<Bytes>,
+ ) -> reqsign_core::Result<http::Response<Bytes>> {
+ self.requests
+ .lock()
+ .expect("lock poisoned")
+ .push(CapturedRequest {
+ method: req.method().clone(),
+ uri: req.uri().to_string(),
+ });
+
+ let resp = self
+ .responses
+ .lock()
+ .expect("lock poisoned")
+ .pop_front()
+ .expect("mock response must exist");
+
+ Ok(http::Response::builder()
+ .status(resp.status)
+ .body(Bytes::from(resp.body))
+ .expect("response must build"))
+ }
+ }
+
+ fn credentials_response(expiration: String, access_key_id: &str) -> String {
+ format!(
+ r#"{{"Code":"Success","AccessKeyId":"{access_key_id}","AccessKeySecret":"secret","SecurityToken":"token","Expiration":"{expiration}"}}"#
+ )
+ }
+
+ #[tokio::test]
+ async fn test_credentials_uri_provider_without_config() -> anyhow::Result<()> {
+ let ctx = Context::new()
+ .with_file_read(TokioFileRead)
+ .with_http_send(SequenceHttpSend::new(Vec::new()))
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::new(),
+ });
+
+ let provider = CredentialsUriCredentialProvider::new();
+ let cred = provider.provide_credential(&ctx).await?;
+ assert!(cred.is_none());
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_credentials_uri_provider_reads_env_uri() -> anyhow::Result<()> {
+ let http_send = SequenceHttpSend::new(vec![MockResponse {
+ status: http::StatusCode::OK,
+ body: credentials_response("2124-05-25T11:45:17Z".to_string(), "uri_access_key"),
+ }]);
+ let ctx = Context::new()
+ .with_file_read(TokioFileRead)
+ .with_http_send(http_send.clone())
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from([(
+ ALIBABA_CLOUD_CREDENTIALS_URI.to_string(),
+ "http://127.0.0.1/credentials".to_string(),
+ )]),
+ });
+
+ let cred = CredentialsUriCredentialProvider::new()
+ .provide_credential(&ctx)
+ .await?
+ .expect("credential must be loaded");
+
+ assert_eq!("uri_access_key", cred.access_key_id);
+ assert_eq!("secret", cred.access_key_secret);
+ assert_eq!(Some("token".to_string()), cred.security_token);
+ assert_eq!(
+ vec![CapturedRequest {
+ method: http::Method::GET,
+ uri: "http://127.0.0.1/credentials".to_string(),
+ }],
+ http_send.requests()
+ );
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_credentials_uri_provider_builder_overrides_env() -> anyhow::Result<()> {
+ let http_send = SequenceHttpSend::new(vec![MockResponse {
+ status: http::StatusCode::OK,
+ body: credentials_response("2124-05-25T11:45:17Z".to_string(), "override_access_key"),
+ }]);
+ let ctx = Context::new()
+ .with_file_read(TokioFileRead)
+ .with_http_send(http_send.clone())
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from([(
+ ALIBABA_CLOUD_CREDENTIALS_URI.to_string(),
+ "http://127.0.0.1/env-credentials".to_string(),
+ )]),
+ });
+
+ let cred = CredentialsUriCredentialProvider::new()
+ .with_uri("http://127.0.0.1/builder-credentials")
+ .provide_credential(&ctx)
+ .await?
+ .expect("credential must be loaded");
+
+ assert_eq!("override_access_key", cred.access_key_id);
+ assert_eq!(
+ vec![CapturedRequest {
+ method: http::Method::GET,
+ uri: "http://127.0.0.1/builder-credentials".to_string(),
+ }],
+ http_send.requests()
+ );
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_credentials_uri_provider_refreshes_via_signer_cache() -> anyhow::Result<()> {
+ let http_send = SequenceHttpSend::new(vec![
+ MockResponse {
+ status: http::StatusCode::OK,
+ body: credentials_response(
+ (Timestamp::now() + Duration::from_secs(60)).format_rfc3339_zulu(),
+ "first_access_key",
+ ),
+ },
+ MockResponse {
+ status: http::StatusCode::OK,
+ body: credentials_response(
+ (Timestamp::now() + Duration::from_secs(600)).format_rfc3339_zulu(),
+ "second_access_key",
+ ),
+ },
+ ]);
+ let ctx = Context::new()
+ .with_file_read(TokioFileRead)
+ .with_http_send(http_send.clone())
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::new(),
+ });
+ let signer = reqsign_core::Signer::new(
+ ctx,
+ CredentialsUriCredentialProvider::new().with_uri("http://127.0.0.1/credentials"),
+ crate::RequestSigner::new("bucket"),
+ );
+
+ let mut req = http::Request::get("https://bucket.oss-cn-beijing.aliyuncs.com/object")
+ .body(())
+ .unwrap()
+ .into_parts()
+ .0;
+ signer.sign(&mut req, None).await?;
+
+ let mut req = http::Request::get("https://bucket.oss-cn-beijing.aliyuncs.com/object")
+ .body(())
+ .unwrap()
+ .into_parts()
+ .0;
+ signer.sign(&mut req, None).await?;
+
+ assert_eq!(2, http_send.call_count());
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_credentials_uri_provider_reuses_valid_credential_via_signer_cache()
+ -> anyhow::Result<()> {
+ let http_send = SequenceHttpSend::new(vec![MockResponse {
+ status: http::StatusCode::OK,
+ body: credentials_response(
+ (Timestamp::now() + Duration::from_secs(600)).format_rfc3339_zulu(),
+ "cached_access_key",
+ ),
+ }]);
+ let ctx = Context::new()
+ .with_file_read(TokioFileRead)
+ .with_http_send(http_send.clone())
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::new(),
+ });
+ let signer = reqsign_core::Signer::new(
+ ctx,
+ CredentialsUriCredentialProvider::new().with_uri("http://127.0.0.1/credentials"),
+ crate::RequestSigner::new("bucket"),
+ );
+
+ let mut req = http::Request::get("https://bucket.oss-cn-beijing.aliyuncs.com/object")
+ .body(())
+ .unwrap()
+ .into_parts()
+ .0;
+ signer.sign(&mut req, None).await?;
+
+ let mut req = http::Request::get("https://bucket.oss-cn-beijing.aliyuncs.com/object")
+ .body(())
+ .unwrap()
+ .into_parts()
+ .0;
+ signer.sign(&mut req, None).await?;
+
+ assert_eq!(1, http_send.call_count());
+
+ Ok(())
+ }
+}
diff --git a/services/aliyun-oss/src/provide_credential/default.rs b/services/aliyun-oss/src/provide_credential/default.rs
index 9e8b1c0..227920d 100644
--- a/services/aliyun-oss/src/provide_credential/default.rs
+++ b/services/aliyun-oss/src/provide_credential/default.rs
@@ -18,7 +18,8 @@
use crate::Credential;
use crate::provide_credential::{
AssumeRoleCredentialProvider, AssumeRoleWithOidcCredentialProvider,
- ConfigFileCredentialProvider, CredentialsFileCredentialProvider, EnvCredentialProvider,
+ ConfigFileCredentialProvider, CredentialsFileCredentialProvider,
+ CredentialsUriCredentialProvider, EcsRamRoleCredentialProvider, EnvCredentialProvider,
OssProfileCredentialProvider,
};
use reqsign_core::{Context, ProvideCredential, ProvideCredentialChain, Result};
@@ -32,7 +33,9 @@ use reqsign_core::{Context, ProvideCredential, ProvideCredentialChain, Result};
/// 3. OSS profile file
/// 4. Alibaba shared credentials file
/// 5. Alibaba CLI config file
-/// 6. Assume Role with OIDC
+/// 6. Credentials URI
+/// 7. ECS RAM role metadata
+/// 8. Assume Role with OIDC
#[derive(Debug)]
pub struct DefaultCredentialProvider {
chain: ProvideCredentialChain<Credential>,
@@ -90,6 +93,8 @@ pub struct DefaultCredentialProviderBuilder {
assume_role: Option<AssumeRoleCredentialProvider>,
env: Option<EnvCredentialProvider>,
oss_profile: Option<OssProfileCredentialProvider>,
+ credentials_uri: Option<CredentialsUriCredentialProvider>,
+ ecs_ram_role: Option<EcsRamRoleCredentialProvider>,
credentials_file: Option<CredentialsFileCredentialProvider>,
config_file: Option<ConfigFileCredentialProvider>,
oidc: Option<AssumeRoleWithOidcCredentialProvider>,
@@ -101,6 +106,8 @@ impl Default for DefaultCredentialProviderBuilder {
assume_role: Some(AssumeRoleCredentialProvider::new()),
env: Some(EnvCredentialProvider::new()),
oss_profile: Some(OssProfileCredentialProvider::new()),
+ credentials_uri: Some(CredentialsUriCredentialProvider::new()),
+ ecs_ram_role: Some(EcsRamRoleCredentialProvider::new()),
credentials_file: Some(CredentialsFileCredentialProvider::new()),
config_file: Some(ConfigFileCredentialProvider::new()),
oidc: Some(AssumeRoleWithOidcCredentialProvider::new()),
@@ -174,6 +181,29 @@ impl DefaultCredentialProviderBuilder {
self
}
+ /// Set the credentials URI credential provider slot.
+ pub fn credentials_uri(mut self, provider: CredentialsUriCredentialProvider) -> Self {
+ self.credentials_uri = Some(provider);
+ self
+ }
+
+ /// Remove the credentials URI credential provider slot.
+ pub fn no_credentials_uri(mut self) -> Self {
+ self.credentials_uri = None;
+ self
+ }
+
+ /// Set the ECS RAM role credential provider slot.
+ pub fn ecs_ram_role(mut self, provider: EcsRamRoleCredentialProvider) -> Self {
+ self.ecs_ram_role = Some(provider);
+ self
+ }
+
+ /// Remove the ECS RAM role credential provider slot.
+ pub fn no_ecs_ram_role(mut self) -> Self {
+ self.ecs_ram_role = None;
+ self
+ }
/// Set the OIDC credential provider slot.
pub fn oidc(mut self, provider: AssumeRoleWithOidcCredentialProvider) -> Self {
self.oidc = Some(provider);
@@ -209,6 +239,12 @@ impl DefaultCredentialProviderBuilder {
if let Some(p) = self.config_file {
chain = chain.push(p);
}
+ if let Some(p) = self.credentials_uri {
+ chain = chain.push(p);
+ }
+ if let Some(p) = self.ecs_ram_role {
+ chain = chain.push(p);
+ }
if let Some(p) = self.oidc {
chain = chain.push(p);
}
@@ -250,9 +286,9 @@ mod tests {
use reqsign_core::{FileRead, HttpSend, OsEnv, StaticEnv};
use reqsign_file_read_tokio::TokioFileRead;
use reqsign_http_send_reqwest::ReqwestHttpSend;
- use std::collections::HashMap;
+ use std::collections::{HashMap, VecDeque};
use std::sync::{
- Arc,
+ Arc, Mutex,
atomic::{AtomicUsize, Ordering},
};
@@ -287,31 +323,47 @@ mod tests {
#[derive(Clone, Debug)]
struct CountingHttpSend {
calls: Arc<AtomicUsize>,
- body: Vec<u8>,
+ requests: Arc<Mutex<Vec<String>>>,
+ responses: Arc<Mutex<VecDeque<Vec<u8>>>>,
}
impl CountingHttpSend {
- fn new(body: impl Into<Vec<u8>>) -> Self {
+ fn new(responses: impl IntoIterator<Item = Vec<u8>>) -> Self {
Self {
calls: Arc::new(AtomicUsize::new(0)),
- body: body.into(),
+ requests: Arc::new(Mutex::new(Vec::new())),
+ responses: Arc::new(Mutex::new(responses.into_iter().collect())),
}
}
fn calls(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
+
+ fn requests(&self) -> Vec<String> {
+ self.requests.lock().expect("lock poisoned").clone()
+ }
}
impl HttpSend for CountingHttpSend {
async fn http_send(
&self,
- _req: http::Request<Bytes>,
+ req: http::Request<Bytes>,
) -> reqsign_core::Result<http::Response<Bytes>> {
self.calls.fetch_add(1, Ordering::SeqCst);
+ self.requests
+ .lock()
+ .expect("lock poisoned")
+ .push(req.uri().to_string());
+ let body = self
+ .responses
+ .lock()
+ .expect("lock poisoned")
+ .pop_front()
+ .unwrap_or_default();
Ok(http::Response::builder()
.status(http::StatusCode::OK)
- .body(Bytes::from(self.body.clone()))
+ .body(Bytes::from(body))
.expect("response must build"))
}
}
@@ -326,7 +378,10 @@ mod tests {
.with_env(OsEnv);
let ctx = ctx.with_env(StaticEnv {
home_dir: None,
- envs: HashMap::new(),
+ envs: HashMap::from([(
+ ALIBABA_CLOUD_ECS_METADATA_DISABLED.to_string(),
+ "true".to_string(),
+ )]),
});
let loader = DefaultCredentialProvider::new();
@@ -479,8 +534,8 @@ access_key_secret = profile_secret_key
("/mock/token".to_string(), b"token".to_vec()),
]));
let http_send = CountingHttpSend::new(
- br#"{"Credentials":{"SecurityToken":"security_token","Expiration":"2124-05-25T11:45:17Z","AccessKeySecret":"oidc_secret_key","AccessKeyId":"oidc_access_key"}}"#
- .to_vec(),
+ [br#"{"Credentials":{"SecurityToken":"security_token","Expiration":"2124-05-25T11:45:17Z","AccessKeySecret":"oidc_secret_key","AccessKeyId":"oidc_access_key"}}"#
+ .to_vec()],
);
let ctx = Context::new()
.with_file_read(file_read.clone())
@@ -569,6 +624,8 @@ access_key_secret=shared_secret_key
.no_assume_role()
.no_env()
.no_oss_profile()
+ .no_credentials_uri()
+ .no_ecs_ram_role()
.no_oidc()
.build()
.provide_credential(&ctx)
@@ -612,6 +669,8 @@ access_key_secret=shared_secret_key
.no_env()
.no_oss_profile()
.no_credentials_file()
+ .no_credentials_uri()
+ .no_ecs_ram_role()
.no_oidc()
.build()
.provide_credential(&ctx)
@@ -655,6 +714,8 @@ access_key_secret=shared_secret_key
.no_env()
.no_oss_profile()
.no_credentials_file()
+ .no_credentials_uri()
+ .no_ecs_ram_role()
.no_oidc()
.build()
.provide_credential(&ctx)
@@ -669,6 +730,8 @@ access_key_secret=shared_secret_key
.no_oss_profile()
.no_credentials_file()
.no_config_file()
+ .no_credentials_uri()
+ .no_ecs_ram_role()
.no_oidc()
.build()
.provide_credential(&ctx)
@@ -696,10 +759,7 @@ access_key_secret=shared_secret_key
),
("/mock/token".to_string(), b"token".to_vec()),
]));
- let http_send = CountingHttpSend::new(
- br#"{"Credentials":{"SecurityToken":"security_token","Expiration":"2124-05-25T11:45:17Z","AccessKeySecret":"oidc_secret_key","AccessKeyId":"oidc_access_key"}}"#
- .to_vec(),
- );
+ let http_send = CountingHttpSend::new([br#"{"Credentials":{"SecurityToken":"security_token","Expiration":"2124-05-25T11:45:17Z","AccessKeySecret":"oidc_secret_key","AccessKeyId":"oidc_access_key"}}"#.to_vec()]);
let ctx = Context::new()
.with_file_read(file_read)
.with_http_send(http_send.clone())
@@ -741,6 +801,231 @@ access_key_secret=shared_secret_key
assert_eq!(0, http_send.calls());
}
+ #[tokio::test]
+ async fn test_default_loader_prefers_credentials_uri_over_ecs_and_oidc() {
+ let file_read = CountingFileRead::new(HashMap::from([(
+ "/mock/token".to_string(),
+ b"token".to_vec(),
+ )]));
+ let http_send = CountingHttpSend::new([br#"{"Code":"Success","AccessKeyId":"uri_access_key","AccessKeySecret":"uri_secret_key","SecurityToken":"uri_token","Expiration":"2124-05-25T11:45:17Z"}"#.to_vec()]);
+ let ctx = Context::new()
+ .with_file_read(file_read)
+ .with_http_send(http_send.clone())
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from_iter([
+ (
+ ALIBABA_CLOUD_CREDENTIALS_URI.to_string(),
+ "http://127.0.0.1/credentials".to_string(),
+ ),
+ (
+ ALIBABA_CLOUD_ECS_METADATA.to_string(),
+ "ecs-role".to_string(),
+ ),
+ (
+ ALIBABA_CLOUD_ECS_METADATA_SERVICE_ENDPOINT.to_string(),
+ "http://127.0.0.1".to_string(),
+ ),
+ (
+ ALIBABA_CLOUD_OIDC_TOKEN_FILE.to_string(),
+ "/mock/token".to_string(),
+ ),
+ (
+ ALIBABA_CLOUD_ROLE_ARN.to_string(),
+ "acs:ram::123456789012:role/test-role".to_string(),
+ ),
+ (
+ ALIBABA_CLOUD_OIDC_PROVIDER_ARN.to_string(),
+ "acs:ram::123456789012:oidc-provider/test-provider".to_string(),
+ ),
+ ]),
+ });
+
+ let credential = DefaultCredentialProvider::builder()
+ .no_env()
+ .no_oss_profile()
+ .no_credentials_file()
+ .no_config_file()
+ .build()
+ .provide_credential(&ctx)
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!("uri_access_key", credential.access_key_id);
+ assert_eq!("uri_secret_key", credential.access_key_secret);
+ assert_eq!(1, http_send.calls());
+ assert_eq!(
+ vec!["http://127.0.0.1/credentials".to_string()],
+ http_send.requests()
+ );
+ }
+
+ #[tokio::test]
+ async fn test_default_loader_prefers_ecs_over_oidc() {
+ let file_read = CountingFileRead::new(HashMap::from([(
+ "/mock/token".to_string(),
+ b"token".to_vec(),
+ )]));
+ let http_send = CountingHttpSend::new([
+ b"metadata-token".to_vec(),
+ br#"{"Code":"Success","AccessKeyId":"ecs_access_key","AccessKeySecret":"ecs_secret_key","SecurityToken":"ecs_token","Expiration":"2124-05-25T11:45:17Z"}"#.to_vec(),
+ ]);
+ let ctx = Context::new()
+ .with_file_read(file_read)
+ .with_http_send(http_send.clone())
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from_iter([
+ (
+ ALIBABA_CLOUD_ECS_METADATA.to_string(),
+ "ecs-role".to_string(),
+ ),
+ (
+ ALIBABA_CLOUD_ECS_METADATA_SERVICE_ENDPOINT.to_string(),
+ "http://127.0.0.1".to_string(),
+ ),
+ (
+ ALIBABA_CLOUD_OIDC_TOKEN_FILE.to_string(),
+ "/mock/token".to_string(),
+ ),
+ (
+ ALIBABA_CLOUD_ROLE_ARN.to_string(),
+ "acs:ram::123456789012:role/test-role".to_string(),
+ ),
+ (
+ ALIBABA_CLOUD_OIDC_PROVIDER_ARN.to_string(),
+ "acs:ram::123456789012:oidc-provider/test-provider".to_string(),
+ ),
+ ]),
+ });
+
+ let credential = DefaultCredentialProvider::builder()
+ .no_env()
+ .no_oss_profile()
+ .no_credentials_file()
+ .no_config_file()
+ .build()
+ .provide_credential(&ctx)
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!("ecs_access_key", credential.access_key_id);
+ assert_eq!("ecs_secret_key", credential.access_key_secret);
+ assert_eq!(2, http_send.calls());
+ assert_eq!(
+ vec![
+ "http://127.0.0.1/latest/api/token".to_string(),
+ "http://127.0.0.1/latest/meta-data/ram/security-credentials/ecs-role".to_string(),
+ ],
+ http_send.requests()
+ );
+ }
+
+ #[tokio::test]
+ async fn test_builder_no_credentials_uri_removes_credentials_uri_provider() {
+ let http_send = CountingHttpSend::new([br#"{"Code":"Success","AccessKeyId":"uri_access_key","AccessKeySecret":"uri_secret_key","SecurityToken":"uri_token","Expiration":"2124-05-25T11:45:17Z"}"#.to_vec()]);
+ let ctx = Context::new()
+ .with_file_read(TokioFileRead)
+ .with_http_send(http_send.clone())
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from([
+ (
+ ALIBABA_CLOUD_CREDENTIALS_URI.to_string(),
+ "http://127.0.0.1/credentials".to_string(),
+ ),
+ (
+ ALIBABA_CLOUD_ECS_METADATA_DISABLED.to_string(),
+ "true".to_string(),
+ ),
+ ]),
+ });
+
+ let credential = DefaultCredentialProvider::builder()
+ .no_env()
+ .no_oss_profile()
+ .no_credentials_file()
+ .no_config_file()
+ .no_ecs_ram_role()
+ .no_oidc()
+ .build()
+ .provide_credential(&ctx)
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!("uri_access_key", credential.access_key_id);
+ assert_eq!(1, http_send.calls());
+
+ let credential = DefaultCredentialProvider::builder()
+ .no_env()
+ .no_oss_profile()
+ .no_credentials_file()
+ .no_config_file()
+ .no_credentials_uri()
+ .no_ecs_ram_role()
+ .no_oidc()
+ .build()
+ .provide_credential(&ctx)
+ .await
+ .unwrap();
+ assert!(credential.is_none());
+ assert_eq!(1, http_send.calls());
+ }
+
+ #[tokio::test]
+ async fn test_builder_no_ecs_ram_role_removes_ecs_provider() {
+ let http_send = CountingHttpSend::new([
+ b"metadata-token".to_vec(),
+ br#"{"Code":"Success","AccessKeyId":"ecs_access_key","AccessKeySecret":"ecs_secret_key","SecurityToken":"ecs_token","Expiration":"2124-05-25T11:45:17Z"}"#.to_vec(),
+ ]);
+ let ctx = Context::new()
+ .with_file_read(TokioFileRead)
+ .with_http_send(http_send.clone())
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from([
+ (
+ ALIBABA_CLOUD_ECS_METADATA.to_string(),
+ "ecs-role".to_string(),
+ ),
+ (
+ ALIBABA_CLOUD_ECS_METADATA_SERVICE_ENDPOINT.to_string(),
+ "http://127.0.0.1".to_string(),
+ ),
+ ]),
+ });
+
+ let credential = DefaultCredentialProvider::builder()
+ .no_env()
+ .no_oss_profile()
+ .no_credentials_file()
+ .no_config_file()
+ .no_credentials_uri()
+ .no_oidc()
+ .build()
+ .provide_credential(&ctx)
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!("ecs_access_key", credential.access_key_id);
+ assert_eq!(2, http_send.calls());
+
+ let credential = DefaultCredentialProvider::builder()
+ .no_env()
+ .no_oss_profile()
+ .no_credentials_file()
+ .no_config_file()
+ .no_credentials_uri()
+ .no_ecs_ram_role()
+ .no_oidc()
+ .build()
+ .provide_credential(&ctx)
+ .await
+ .unwrap();
+ assert!(credential.is_none());
+ assert_eq!(2, http_send.calls());
+ }
+
#[tokio::test]
async fn test_builder_no_oidc_removes_oidc_provider() {
let file_read = CountingFileRead::new(HashMap::from([(
@@ -748,8 +1033,8 @@ access_key_secret=shared_secret_key
b"token".to_vec(),
)]));
let http_send = CountingHttpSend::new(
- br#"{"Credentials":{"SecurityToken":"security_token","Expiration":"2124-05-25T11:45:17Z","AccessKeySecret":"secret_access_key","AccessKeyId":"access_key_id"}}"#
- .to_vec(),
+ [br#"{"Credentials":{"SecurityToken":"security_token","Expiration":"2124-05-25T11:45:17Z","AccessKeySecret":"secret_access_key","AccessKeyId":"access_key_id"}}"#
+ .to_vec()],
);
let ctx = Context::new()
.with_file_read(file_read.clone())
@@ -776,6 +1061,8 @@ access_key_secret=shared_secret_key
.no_assume_role()
.no_env()
.no_oss_profile()
+ .no_credentials_uri()
+ .no_ecs_ram_role()
.oidc(AssumeRoleWithOidcCredentialProvider::new())
.build()
.provide_credential(&ctx)
@@ -789,6 +1076,8 @@ access_key_secret=shared_secret_key
.no_assume_role()
.no_env()
.no_oss_profile()
+ .no_credentials_uri()
+ .no_ecs_ram_role()
.no_oidc()
.build()
.provide_credential(&ctx)
@@ -799,10 +1088,10 @@ access_key_secret=shared_secret_key
#[tokio::test]
async fn test_default_loader_prefers_assume_role_over_raw_env_credentials() {
- let http_send = CountingHttpSend::new(
+ let http_send = CountingHttpSend::new([
br#"{"Credentials":{"SecurityToken":"sts_token","Expiration":"2124-05-25T11:45:17Z","AccessKeySecret":"assumed_secret_key","AccessKeyId":"assumed_access_key"}}"#
.to_vec(),
- );
+ ]);
let ctx = Context::new()
.with_file_read(TokioFileRead)
.with_http_send(http_send.clone())
@@ -840,10 +1129,10 @@ access_key_secret=shared_secret_key
#[tokio::test]
async fn test_builder_no_env_removes_env_from_assume_role_base_chain() {
- let http_send = CountingHttpSend::new(
+ let http_send = CountingHttpSend::new([
br#"{"Credentials":{"SecurityToken":"sts_token","Expiration":"2124-05-25T11:45:17Z","AccessKeySecret":"assumed_secret_key","AccessKeyId":"assumed_access_key"}}"#
.to_vec(),
- );
+ ]);
let ctx = Context::new()
.with_file_read(TokioFileRead)
.with_http_send(http_send.clone())
@@ -870,6 +1159,7 @@ access_key_secret=shared_secret_key
.no_oss_profile()
.no_credentials_file()
.no_config_file()
+ .no_ecs_ram_role()
.no_oidc()
.build()
.provide_credential(&ctx)
@@ -882,10 +1172,10 @@ access_key_secret=shared_secret_key
#[tokio::test]
async fn test_builder_no_assume_role_removes_assume_role_provider() {
- let http_send = CountingHttpSend::new(
+ let http_send = CountingHttpSend::new([
br#"{"Credentials":{"SecurityToken":"sts_token","Expiration":"2124-05-25T11:45:17Z","AccessKeySecret":"assumed_secret_key","AccessKeyId":"assumed_access_key"}}"#
.to_vec(),
- );
+ ]);
let ctx = Context::new()
.with_file_read(TokioFileRead)
.with_http_send(http_send.clone())
diff --git a/services/aliyun-oss/src/provide_credential/ecs_ram_role.rs b/services/aliyun-oss/src/provide_credential/ecs_ram_role.rs
new file mode 100644
index 0000000..53da0af
--- /dev/null
+++ b/services/aliyun-oss/src/provide_credential/ecs_ram_role.rs
@@ -0,0 +1,618 @@
+// 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.
+
+use crate::Credential;
+use crate::constants::{
+ ALIBABA_CLOUD_ECS_METADATA, ALIBABA_CLOUD_ECS_METADATA_DISABLED,
+ ALIBABA_CLOUD_ECS_METADATA_SERVICE_ENDPOINT, ALIBABA_CLOUD_IMDSV1_DISABLED,
+};
+use reqsign_core::time::Timestamp;
+use reqsign_core::{Context, Error, ProvideCredential, Result};
+use serde::Deserialize;
+
+const DEFAULT_ECS_METADATA_ENDPOINT: &str = "http://100.100.100.200";
+const ECS_METADATA_TOKEN_TTL_SECONDS: u64 = 21600;
+
+/// EcsRamRoleCredentialProvider loads temporary credentials from the ECS metadata service.
+///
+/// Configuration values can be provided directly via builder methods or through
+/// the following environment variables:
+/// - `ALIBABA_CLOUD_ECS_METADATA`: Optional ECS RAM role name
+/// - `ALIBABA_CLOUD_ECS_METADATA_DISABLED`: Disable the provider when set to `true`
+/// - `ALIBABA_CLOUD_IMDSV1_DISABLED`: Require IMDSv2 token requests when set to `true`
+/// - `ALIBABA_CLOUD_ECS_METADATA_SERVICE_ENDPOINT`: Override the metadata endpoint
+#[derive(Debug, Default, Clone)]
+pub struct EcsRamRoleCredentialProvider {
+ endpoint: Option<String>,
+ role_name: Option<String>,
+ disable_imdsv1: Option<bool>,
+}
+
+impl EcsRamRoleCredentialProvider {
+ /// Create a new `EcsRamRoleCredentialProvider` instance.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Set the metadata service endpoint directly.
+ ///
+ /// This setting takes precedence over `ALIBABA_CLOUD_ECS_METADATA_SERVICE_ENDPOINT`.
+ pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
+ self.endpoint = Some(endpoint.into());
+ self
+ }
+
+ /// Set the ECS RAM role name directly.
+ ///
+ /// This setting takes precedence over `ALIBABA_CLOUD_ECS_METADATA`.
+ pub fn with_role_name(mut self, role_name: impl Into<String>) -> Self {
+ self.role_name = Some(role_name.into());
+ self
+ }
+
+ /// Configure whether IMDSv1 fallback is disabled.
+ ///
+ /// This setting takes precedence over `ALIBABA_CLOUD_IMDSV1_DISABLED`.
+ pub fn with_disable_imdsv1(mut self, disable: bool) -> Self {
+ self.disable_imdsv1 = Some(disable);
+ self
+ }
+
+ fn is_disabled(&self, ctx: &Context) -> bool {
+ parse_bool_env(ctx.env_var(ALIBABA_CLOUD_ECS_METADATA_DISABLED))
+ }
+
+ fn disable_imdsv1(&self, ctx: &Context) -> bool {
+ self.disable_imdsv1
+ .unwrap_or_else(|| parse_bool_env(ctx.env_var(ALIBABA_CLOUD_IMDSV1_DISABLED)))
+ }
+
+ fn get_endpoint(&self, ctx: &Context) -> String {
+ self.endpoint
+ .clone()
+ .or_else(|| ctx.env_var(ALIBABA_CLOUD_ECS_METADATA_SERVICE_ENDPOINT))
+ .filter(|endpoint| !endpoint.is_empty())
+ .unwrap_or_else(|| DEFAULT_ECS_METADATA_ENDPOINT.to_string())
+ }
+
+ fn get_role_name(&self, ctx: &Context) -> Option<String> {
+ self.role_name
+ .clone()
+ .or_else(|| ctx.env_var(ALIBABA_CLOUD_ECS_METADATA))
+ .filter(|role_name| !role_name.is_empty())
+ }
+
+ async fn get_metadata_token(&self, ctx: &Context) -> Result<Option<String>> {
+ let endpoint = self.get_endpoint(ctx);
+ let url = format!("{endpoint}/latest/api/token");
+ let req = http::Request::builder()
+ .method(http::Method::PUT)
+ .uri(&url)
+ .header(
+ "X-aliyun-ecs-metadata-token-ttl-seconds",
+ ECS_METADATA_TOKEN_TTL_SECONDS.to_string(),
+ )
+ .body(Vec::new())
+ .map_err(|e| {
+ Error::request_invalid("failed to build ECS metadata token request")
+ .with_source(e)
+ .with_context(format!("url: {url}"))
+ })?;
+
+ let resp = ctx.http_send_as_string(req.map(Into::into)).await;
+ match resp {
+ Ok(resp) if resp.status() == http::StatusCode::OK => {
+ Ok(Some(resp.into_body().trim().to_string()))
+ }
+ Ok(resp) if self.disable_imdsv1(ctx) => Err(Error::unexpected(format!(
+ "failed to fetch ECS metadata token: {}",
+ resp.into_body()
+ ))
+ .with_context(format!("url: {url}"))),
+ Ok(_) => Ok(None),
+ Err(err) if self.disable_imdsv1(ctx) => {
+ Err(Error::unexpected("failed to fetch ECS metadata token")
+ .with_source(err)
+ .with_context(format!("url: {url}")))
+ }
+ Err(_) => Ok(None),
+ }
+ }
+
+ async fn load_role_name(&self, ctx: &Context, metadata_token: Option<&str>) -> Result<String> {
+ let endpoint = self.get_endpoint(ctx);
+ let url = format!("{endpoint}/latest/meta-data/ram/security-credentials/");
+ let mut req = http::Request::builder()
+ .method(http::Method::GET)
+ .uri(&url)
+ .body(Vec::new())
+ .map_err(|e| {
+ Error::request_invalid("failed to build ECS role name request")
+ .with_source(e)
+ .with_context(format!("url: {url}"))
+ })?;
+
+ if let Some(metadata_token) = metadata_token {
+ req.headers_mut().insert(
+ "X-aliyun-ecs-metadata-token",
+ metadata_token.parse().map_err(|e| {
+ Error::config_invalid("invalid ECS metadata token")
+ .with_source(e)
+ .with_context(format!("url: {url}"))
+ })?,
+ );
+ }
+
+ let resp = ctx
+ .http_send_as_string(req.map(Into::into))
+ .await
+ .map_err(|e| {
+ Error::unexpected("failed to fetch ECS role name")
+ .with_source(e)
+ .with_context(format!("url: {url}"))
+ .set_retryable(true)
+ })?;
+
+ if resp.status() != http::StatusCode::OK {
+ return Err(Error::unexpected(format!(
+ "failed to fetch ECS role name: {}",
+ resp.into_body()
+ ))
+ .with_context(format!("url: {url}")));
+ }
+
+ let role_name = resp
+ .into_body()
+ .lines()
+ .next()
+ .unwrap_or_default()
+ .trim()
+ .to_string();
+
+ if role_name.is_empty() {
+ return Err(
+ Error::config_invalid("ECS metadata did not return a RAM role name")
+ .with_context(format!("url: {url}")),
+ );
+ }
+
+ Ok(role_name)
+ }
+
+ async fn load_credentials(
+ &self,
+ ctx: &Context,
+ metadata_token: Option<&str>,
+ role_name: &str,
+ ) -> Result<Credential> {
+ let endpoint = self.get_endpoint(ctx);
+ let url = format!("{endpoint}/latest/meta-data/ram/security-credentials/{role_name}");
+ let mut req = http::Request::builder()
+ .method(http::Method::GET)
+ .uri(&url)
+ .body(Vec::new())
+ .map_err(|e| {
+ Error::request_invalid("failed to build ECS credential request")
+ .with_source(e)
+ .with_context(format!("url: {url}"))
+ })?;
+
+ if let Some(metadata_token) = metadata_token {
+ req.headers_mut().insert(
+ "X-aliyun-ecs-metadata-token",
+ metadata_token.parse().map_err(|e| {
+ Error::config_invalid("invalid ECS metadata token")
+ .with_source(e)
+ .with_context(format!("url: {url}"))
+ })?,
+ );
+ }
+
+ let resp = ctx
+ .http_send_as_string(req.map(Into::into))
+ .await
+ .map_err(|e| {
+ Error::unexpected("failed to fetch ECS RAM role credentials")
+ .with_source(e)
+ .with_context(format!("url: {url}"))
+ .set_retryable(true)
+ })?;
+
+ let status = resp.status();
+ let body = resp.into_body();
+ if status != http::StatusCode::OK {
+ let error = match status.as_u16() {
+ 401 | 403 => Error::permission_denied(format!(
+ "ECS metadata denied credential access: {body}"
+ )),
+ 404 => Error::config_invalid("ECS RAM role credentials not found")
+ .with_context(format!("role_name: {role_name}")),
+ 500..=599 => Error::unexpected(format!("ECS metadata returned {status}: {body}"))
+ .set_retryable(true),
+ _ => Error::unexpected(format!("ECS metadata returned {status}: {body}")),
+ };
+ return Err(error.with_context(format!("url: {url}")));
+ }
+
+ let resp: EcsRamRoleResponse = serde_json::from_str(&body).map_err(|e| {
+ Error::unexpected("failed to parse ECS RAM role credentials")
+ .with_source(e)
+ .with_context(format!("url: {url}"))
+ })?;
+
+ if !resp.code.is_empty() && resp.code != "Success" {
+ return Err(Error::unexpected(format!(
+ "ECS metadata returned error code {}",
+ resp.code
+ ))
+ .with_context(format!("role_name: {role_name}")));
+ }
+
+ if resp.access_key_id.is_empty()
+ || resp.access_key_secret.is_empty()
+ || resp.security_token.is_empty()
+ || resp.expiration.is_empty()
+ {
+ return Err(Error::credential_invalid(
+ "ECS metadata response is missing required fields",
+ )
+ .with_context(format!("role_name: {role_name}")));
+ }
+
+ let expires_in: Timestamp = resp.expiration.parse().map_err(|e| {
+ Error::credential_invalid("failed to parse ECS credential expiration")
+ .with_source(e)
+ .with_context(format!("role_name: {role_name}"))
+ })?;
+
+ Ok(Credential {
+ access_key_id: resp.access_key_id,
+ access_key_secret: resp.access_key_secret,
+ security_token: Some(resp.security_token),
+ expires_in: Some(expires_in),
+ })
+ }
+}
+
+impl ProvideCredential for EcsRamRoleCredentialProvider {
+ type Credential = Credential;
+
+ async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
+ if self.is_disabled(ctx) {
+ return Ok(None);
+ }
+
+ let metadata_token = self.get_metadata_token(ctx).await?;
+ let role_name = match self.get_role_name(ctx) {
+ Some(role_name) => role_name,
+ None => self.load_role_name(ctx, metadata_token.as_deref()).await?,
+ };
+
+ let cred = self
+ .load_credentials(ctx, metadata_token.as_deref(), &role_name)
+ .await?;
+ Ok(Some(cred))
+ }
+}
+
+#[derive(Default, Debug, Deserialize)]
+#[serde(default, rename_all = "PascalCase")]
+struct EcsRamRoleResponse {
+ code: String,
+ access_key_id: String,
+ access_key_secret: String,
+ security_token: String,
+ expiration: String,
+}
+
+fn parse_bool_env(value: Option<String>) -> bool {
+ value
+ .map(|value| value.eq_ignore_ascii_case("true"))
+ .unwrap_or(false)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use bytes::Bytes;
+ use reqsign_core::time::Timestamp;
+ use reqsign_core::{Context, HttpSend, StaticEnv};
+ use reqsign_file_read_tokio::TokioFileRead;
+ use std::collections::{HashMap, VecDeque};
+ use std::sync::{Arc, Mutex};
+ use std::time::Duration;
+
+ #[derive(Debug, Clone)]
+ struct MockResponse {
+ status: http::StatusCode,
+ body: String,
+ }
+
+ #[derive(Debug, Clone)]
+ struct CapturedRequest {
+ method: http::Method,
+ uri: String,
+ metadata_token: Option<String>,
+ metadata_token_ttl: Option<String>,
+ }
+
+ #[derive(Debug, Clone)]
+ struct SequenceHttpSend {
+ requests: Arc<Mutex<Vec<CapturedRequest>>>,
+ responses: Arc<Mutex<VecDeque<MockResponse>>>,
+ }
+
+ impl SequenceHttpSend {
+ fn new(responses: Vec<MockResponse>) -> Self {
+ Self {
+ requests: Arc::new(Mutex::new(Vec::new())),
+ responses: Arc::new(Mutex::new(responses.into())),
+ }
+ }
+
+ fn requests(&self) -> Vec<CapturedRequest> {
+ self.requests.lock().expect("lock poisoned").clone()
+ }
+
+ fn call_count(&self) -> usize {
+ self.requests.lock().expect("lock poisoned").len()
+ }
+ }
+
+ impl HttpSend for SequenceHttpSend {
+ async fn http_send(
+ &self,
+ req: http::Request<Bytes>,
+ ) -> reqsign_core::Result<http::Response<Bytes>> {
+ let metadata_token = req
+ .headers()
+ .get("X-aliyun-ecs-metadata-token")
+ .and_then(|value| value.to_str().ok())
+ .map(ToString::to_string);
+ let metadata_token_ttl = req
+ .headers()
+ .get("X-aliyun-ecs-metadata-token-ttl-seconds")
+ .and_then(|value| value.to_str().ok())
+ .map(ToString::to_string);
+
+ self.requests
+ .lock()
+ .expect("lock poisoned")
+ .push(CapturedRequest {
+ method: req.method().clone(),
+ uri: req.uri().to_string(),
+ metadata_token,
+ metadata_token_ttl,
+ });
+
+ let resp = self
+ .responses
+ .lock()
+ .expect("lock poisoned")
+ .pop_front()
+ .expect("mock response must exist");
+
+ Ok(http::Response::builder()
+ .status(resp.status)
+ .body(Bytes::from(resp.body))
+ .expect("response must build"))
+ }
+ }
+
+ fn ecs_credential_response(expiration: String, access_key_id: &str) -> String {
+ format!(
+ r#"{{"Code":"Success","AccessKeyId":"{access_key_id}","AccessKeySecret":"secret","SecurityToken":"token","Expiration":"{expiration}"}}"#
+ )
+ }
+
+ #[tokio::test]
+ async fn test_ecs_ram_role_provider_disabled_by_env() -> anyhow::Result<()> {
+ let ctx = Context::new()
+ .with_file_read(TokioFileRead)
+ .with_http_send(SequenceHttpSend::new(Vec::new()))
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from([(
+ ALIBABA_CLOUD_ECS_METADATA_DISABLED.to_string(),
+ "true".to_string(),
+ )]),
+ });
+
+ let cred = EcsRamRoleCredentialProvider::new()
+ .provide_credential(&ctx)
+ .await?;
+ assert!(cred.is_none());
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_ecs_ram_role_provider_uses_imdsv2_token() -> anyhow::Result<()> {
+ let http_send = SequenceHttpSend::new(vec![
+ MockResponse {
+ status: http::StatusCode::OK,
+ body: "metadata-token".to_string(),
+ },
+ MockResponse {
+ status: http::StatusCode::OK,
+ body: ecs_credential_response("2124-05-25T11:45:17Z".to_string(), "ecs_access_key"),
+ },
+ ]);
+ let ctx = Context::new()
+ .with_file_read(TokioFileRead)
+ .with_http_send(http_send.clone())
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from([(
+ ALIBABA_CLOUD_ECS_METADATA.to_string(),
+ "test-role".to_string(),
+ )]),
+ });
+
+ let cred = EcsRamRoleCredentialProvider::new()
+ .provide_credential(&ctx)
+ .await?
+ .expect("credential must be loaded");
+
+ assert_eq!("ecs_access_key", cred.access_key_id);
+ let requests = http_send.requests();
+ assert_eq!(2, requests.len());
+ assert_eq!(http::Method::PUT, requests[0].method);
+ assert_eq!(
+ Some(ECS_METADATA_TOKEN_TTL_SECONDS.to_string()),
+ requests[0].metadata_token_ttl
+ );
+ assert_eq!(
+ "http://100.100.100.200/latest/meta-data/ram/security-credentials/test-role",
+ requests[1].uri
+ );
+ assert_eq!(
+ Some("metadata-token".to_string()),
+ requests[1].metadata_token
+ );
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_ecs_ram_role_provider_falls_back_to_imdsv1() -> anyhow::Result<()> {
+ let http_send = SequenceHttpSend::new(vec![
+ MockResponse {
+ status: http::StatusCode::INTERNAL_SERVER_ERROR,
+ body: "token failed".to_string(),
+ },
+ MockResponse {
+ status: http::StatusCode::OK,
+ body: "test-role".to_string(),
+ },
+ MockResponse {
+ status: http::StatusCode::OK,
+ body: ecs_credential_response("2124-05-25T11:45:17Z".to_string(), "ecs_access_key"),
+ },
+ ]);
+ let ctx = Context::new()
+ .with_file_read(TokioFileRead)
+ .with_http_send(http_send.clone())
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::new(),
+ });
+
+ let cred = EcsRamRoleCredentialProvider::new()
+ .with_endpoint("http://127.0.0.1")
+ .provide_credential(&ctx)
+ .await?
+ .expect("credential must be loaded");
+
+ assert_eq!("ecs_access_key", cred.access_key_id);
+ let requests = http_send.requests();
+ assert_eq!(3, requests.len());
+ assert_eq!(None, requests[1].metadata_token);
+ assert_eq!(None, requests[2].metadata_token);
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_ecs_ram_role_provider_respects_imdsv1_disable() {
+ let http_send = SequenceHttpSend::new(vec![MockResponse {
+ status: http::StatusCode::INTERNAL_SERVER_ERROR,
+ body: "token failed".to_string(),
+ }]);
+ let ctx = Context::new()
+ .with_file_read(TokioFileRead)
+ .with_http_send(http_send)
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from([(
+ ALIBABA_CLOUD_IMDSV1_DISABLED.to_string(),
+ "true".to_string(),
+ )]),
+ });
+
+ let err = EcsRamRoleCredentialProvider::new()
+ .with_endpoint("http://127.0.0.1")
+ .provide_credential(&ctx)
+ .await
+ .expect_err("provider must fail when IMDSv1 is disabled");
+
+ assert!(
+ err.to_string()
+ .contains("failed to fetch ECS metadata token")
+ );
+ }
+
+ #[tokio::test]
+ async fn test_ecs_ram_role_provider_refreshes_via_signer_cache() -> anyhow::Result<()> {
+ let http_send = SequenceHttpSend::new(vec![
+ MockResponse {
+ status: http::StatusCode::OK,
+ body: "metadata-token".to_string(),
+ },
+ MockResponse {
+ status: http::StatusCode::OK,
+ body: ecs_credential_response(
+ (Timestamp::now() + Duration::from_secs(60)).format_rfc3339_zulu(),
+ "first_access_key",
+ ),
+ },
+ MockResponse {
+ status: http::StatusCode::OK,
+ body: "metadata-token-2".to_string(),
+ },
+ MockResponse {
+ status: http::StatusCode::OK,
+ body: ecs_credential_response(
+ (Timestamp::now() + Duration::from_secs(600)).format_rfc3339_zulu(),
+ "second_access_key",
+ ),
+ },
+ ]);
+ let ctx = Context::new()
+ .with_file_read(TokioFileRead)
+ .with_http_send(http_send.clone())
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::new(),
+ });
+ let signer = reqsign_core::Signer::new(
+ ctx,
+ EcsRamRoleCredentialProvider::new()
+ .with_endpoint("http://127.0.0.1")
+ .with_role_name("test-role"),
+ crate::RequestSigner::new("bucket"),
+ );
+
+ let mut req = http::Request::get("https://bucket.oss-cn-beijing.aliyuncs.com/object")
+ .body(())
+ .unwrap()
+ .into_parts()
+ .0;
+ signer.sign(&mut req, None).await?;
+
+ let mut req = http::Request::get("https://bucket.oss-cn-beijing.aliyuncs.com/object")
+ .body(())
+ .unwrap()
+ .into_parts()
+ .0;
+ signer.sign(&mut req, None).await?;
+
+ assert_eq!(4, http_send.call_count());
+
+ Ok(())
+ }
+}
diff --git a/services/aliyun-oss/src/provide_credential/mod.rs b/services/aliyun-oss/src/provide_credential/mod.rs
index 48b5dff..867cc74 100644
--- a/services/aliyun-oss/src/provide_credential/mod.rs
+++ b/services/aliyun-oss/src/provide_credential/mod.rs
@@ -27,9 +27,15 @@ pub use config_file::ConfigFileCredentialProvider;
mod credentials_file;
pub use credentials_file::CredentialsFileCredentialProvider;
+mod credentials_uri;
+pub use credentials_uri::CredentialsUriCredentialProvider;
+
mod default;
pub use default::{DefaultCredentialProvider, DefaultCredentialProviderBuilder};
+mod ecs_ram_role;
+pub use ecs_ram_role::EcsRamRoleCredentialProvider;
+
mod env;
pub use env::EnvCredentialProvider;