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 3dc1cf1 feat(aliyun-oss): add assume role credential provider (#724)
3dc1cf1 is described below
commit 3dc1cf19b2d69b35459cbcecabbd488bf0e69a5b
Author: Xuanwo <[email protected]>
AuthorDate: Thu Mar 19 02:50:16 2026 +0800
feat(aliyun-oss): add assume role credential provider (#724)
This PR adds an AK-based AssumeRole provider for `reqsign-aliyun-oss`
and wires it into the default credential builder.
It extends the OSS credential parity work with a non-recursive base
provider chain and keeps the builder semantics aligned with the new
slot-based API.
---
services/aliyun-oss/README.md | 41 +-
services/aliyun-oss/examples/oss_operations.rs | 7 +-
services/aliyun-oss/src/constants.rs | 1 +
services/aliyun-oss/src/lib.rs | 35 +-
.../src/provide_credential/assume_role.rs | 640 +++++++++++++++++++++
.../aliyun-oss/src/provide_credential/default.rs | 196 ++++++-
services/aliyun-oss/src/provide_credential/mod.rs | 3 +
7 files changed, 896 insertions(+), 27 deletions(-)
diff --git a/services/aliyun-oss/README.md b/services/aliyun-oss/README.md
index 9deae14..27582ff 100644
--- a/services/aliyun-oss/README.md
+++ b/services/aliyun-oss/README.md
@@ -10,9 +10,10 @@ This crate provides signing support for Alibaba Cloud Object
Storage Service (OS
```rust
use reqsign_aliyun_oss::{
- AssumeRoleWithOidcCredentialProvider, ConfigFileCredentialProvider,
- CredentialsFileCredentialProvider, DefaultCredentialProvider,
EnvCredentialProvider,
- OssProfileCredentialProvider, RequestSigner, SigningVersion,
StaticCredentialProvider,
+ AssumeRoleCredentialProvider, AssumeRoleWithOidcCredentialProvider,
+ ConfigFileCredentialProvider, CredentialsFileCredentialProvider,
DefaultCredentialProvider,
+ EnvCredentialProvider, OssProfileCredentialProvider, RequestSigner,
+ SigningVersion, StaticCredentialProvider,
};
use reqsign_core::{Context, Result, Signer};
use reqsign_file_read_tokio::TokioFileRead;
@@ -25,6 +26,7 @@ async fn main() -> Result<()> {
.with_http_send(ReqwestHttpSend::default());
let loader = DefaultCredentialProvider::builder()
+ .assume_role(AssumeRoleCredentialProvider::new())
.env(EnvCredentialProvider::new())
.oss_profile(OssProfileCredentialProvider::new())
.credentials_file(CredentialsFileCredentialProvider::new())
@@ -61,8 +63,7 @@ async fn main() -> Result<()> {
## Features
-- **V1 and V4 Signing**: Supports both legacy OSS V1 signatures and Signature
V4
-- **Multiple Credential Sources**: Environment variables, OSS profile files,
Alibaba shared credential/config files, and OIDC-based STS exchange
+- **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
- **STS Support**: Temporary credentials via Security Token Service
- **All OSS Operations**: Object, bucket, and multipart operations
@@ -179,6 +180,36 @@ let loader = DefaultCredentialProvider::builder()
The session name defaults to `reqsign`. To customize it, set
`ALIBABA_CLOUD_ROLE_SESSION_NAME` or use
`AssumeRoleWithOidcCredentialProvider::with_role_session_name`.
+### STS AssumeRole with Base AK Credentials
+
+```rust
+use reqsign_aliyun_oss::{
+ AssumeRoleCredentialProvider, DefaultCredentialProvider,
StaticCredentialProvider,
+};
+
+// Use an explicit base access key source to call STS AssumeRole.
+let loader = DefaultCredentialProvider::builder()
+ .no_env()
+ .no_oss_profile()
+ .no_credentials_file()
+ .no_config_file()
+ .assume_role(
+ AssumeRoleCredentialProvider::new()
+ .with_base_provider(StaticCredentialProvider::new(
+ "your-access-key-id",
+ "your-access-key-secret",
+ ))
+ .with_role_arn("acs:ram::123456789012:role/example")
+ .with_role_session_name("my-session"),
+ )
+ .no_oidc()
+ .build();
+```
+
+Or rely on the default static base chain by setting
+`ALIBABA_CLOUD_ACCESS_KEY_ID`, `ALIBABA_CLOUD_ACCESS_KEY_SECRET`,
+`ALIBABA_CLOUD_ROLE_ARN`, and optionally `ALIBABA_CLOUD_EXTERNAL_ID`.
+
## OSS Operations
### Object Operations
diff --git a/services/aliyun-oss/examples/oss_operations.rs
b/services/aliyun-oss/examples/oss_operations.rs
index 3de6745..09b5c6c 100644
--- a/services/aliyun-oss/examples/oss_operations.rs
+++ b/services/aliyun-oss/examples/oss_operations.rs
@@ -16,8 +16,8 @@
// under the License.
use reqsign_aliyun_oss::{
- AssumeRoleWithOidcCredentialProvider, DefaultCredentialProvider,
EnvCredentialProvider,
- RequestSigner, StaticCredentialProvider,
+ AssumeRoleCredentialProvider, AssumeRoleWithOidcCredentialProvider,
DefaultCredentialProvider,
+ EnvCredentialProvider, RequestSigner, StaticCredentialProvider,
};
use reqsign_core::Result;
use reqsign_core::{Context, OsEnv, Signer};
@@ -62,8 +62,9 @@ async fn main() -> Result<()> {
StaticCredentialProvider::new("LTAI4GDemoAccessKeyId",
"DemoAccessKeySecretForExample");
Signer::new(ctx.clone(), loader, builder)
} else {
- // Build the default env -> oidc chain explicitly via slot APIs.
+ // Build the default assume_role -> env -> oidc chain explicitly via
slot APIs.
let loader = DefaultCredentialProvider::builder()
+ .assume_role(AssumeRoleCredentialProvider::new())
.env(EnvCredentialProvider::new())
.oidc(AssumeRoleWithOidcCredentialProvider::new())
.build();
diff --git a/services/aliyun-oss/src/constants.rs
b/services/aliyun-oss/src/constants.rs
index 3657fa2..ca20192 100644
--- a/services/aliyun-oss/src/constants.rs
+++ b/services/aliyun-oss/src/constants.rs
@@ -29,6 +29,7 @@ pub const OSS_PROFILE: &str = "OSS_PROFILE";
pub const OSS_CREDENTIAL_PROFILES_FILE: &str = "OSS_CREDENTIAL_PROFILES_FILE";
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";
pub const ALIBABA_CLOUD_OIDC_PROVIDER_ARN: &str =
"ALIBABA_CLOUD_OIDC_PROVIDER_ARN";
pub const ALIBABA_CLOUD_OIDC_TOKEN_FILE: &str =
"ALIBABA_CLOUD_OIDC_TOKEN_FILE";
pub const ALIBABA_CLOUD_STS_ENDPOINT: &str = "ALIBABA_CLOUD_STS_ENDPOINT";
diff --git a/services/aliyun-oss/src/lib.rs b/services/aliyun-oss/src/lib.rs
index 80eb8ec..b10a938 100644
--- a/services/aliyun-oss/src/lib.rs
+++ b/services/aliyun-oss/src/lib.rs
@@ -33,9 +33,10 @@
//!
//! ```no_run
//! use reqsign_aliyun_oss::{
-//! AssumeRoleWithOidcCredentialProvider, ConfigFileCredentialProvider,
-//! CredentialsFileCredentialProvider, DefaultCredentialProvider,
EnvCredentialProvider,
-//! OssProfileCredentialProvider, RequestSigner, SigningVersion,
StaticCredentialProvider,
+//! AssumeRoleCredentialProvider, AssumeRoleWithOidcCredentialProvider,
+//! ConfigFileCredentialProvider, CredentialsFileCredentialProvider,
DefaultCredentialProvider,
+//! EnvCredentialProvider, OssProfileCredentialProvider, RequestSigner,
+//! SigningVersion, StaticCredentialProvider,
//! };
//! use reqsign_core::{Context, Signer, Result};
//! use reqsign_file_read_tokio::TokioFileRead;
@@ -48,9 +49,10 @@
//! .with_file_read(TokioFileRead::default())
//! .with_http_send(ReqwestHttpSend::default());
//!
-//! // Create credential loader with the default env -> OSS profile ->
-//! // shared credentials file -> config file -> oidc chain.
+//! // Create credential loader with the default assume_role -> env ->
+//! // OSS profile -> shared credentials file -> config file -> oidc chain.
//! let loader = DefaultCredentialProvider::builder()
+//! .assume_role(AssumeRoleCredentialProvider::new())
//! .env(EnvCredentialProvider::new())
//! .oss_profile(OssProfileCredentialProvider::new())
//! .credentials_file(CredentialsFileCredentialProvider::new())
@@ -153,22 +155,33 @@
//! ### STS AssumeRole
//!
//! ```no_run
-//! use reqsign_aliyun_oss::{AssumeRoleWithOidcCredentialProvider,
DefaultCredentialProvider};
+//! use reqsign_aliyun_oss::{
+//! AssumeRoleCredentialProvider, DefaultCredentialProvider,
StaticCredentialProvider,
+//! };
//!
-//! // Use environment variables
-//! // Set ALIBABA_CLOUD_ROLE_ARN, ALIBABA_CLOUD_OIDC_PROVIDER_ARN,
ALIBABA_CLOUD_OIDC_TOKEN_FILE
-//! // Optionally set ALIBABA_CLOUD_ROLE_SESSION_NAME
+//! // Use an explicit base access key source to call STS AssumeRole.
//! let loader = DefaultCredentialProvider::builder()
//! .no_env()
//! .no_oss_profile()
//! .no_credentials_file()
//! .no_config_file()
-//! .oidc(
-//!
AssumeRoleWithOidcCredentialProvider::new().with_role_session_name("my-session"),
+//! .assume_role(
+//! AssumeRoleCredentialProvider::new()
+//! .with_base_provider(StaticCredentialProvider::new(
+//! "your-access-key-id",
+//! "your-access-key-secret",
+//! ))
+//! .with_role_arn("acs:ram::123456789012:role/example")
+//! .with_role_session_name("my-session"),
//! )
+//! .no_oidc()
//! .build();
//! ```
//!
+//! Or rely on the default static base chain by setting
+//! `ALIBABA_CLOUD_ACCESS_KEY_ID`, `ALIBABA_CLOUD_ACCESS_KEY_SECRET`,
+//! `ALIBABA_CLOUD_ROLE_ARN`, and optionally `ALIBABA_CLOUD_EXTERNAL_ID`.
+//!
//! ### Custom Endpoints
//!
//! ```no_run
diff --git a/services/aliyun-oss/src/provide_credential/assume_role.rs
b/services/aliyun-oss/src/provide_credential/assume_role.rs
new file mode 100644
index 0000000..62391c2
--- /dev/null
+++ b/services/aliyun-oss/src/provide_credential/assume_role.rs
@@ -0,0 +1,640 @@
+// 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::provide_credential::{
+ ConfigFileCredentialProvider, CredentialsFileCredentialProvider,
EnvCredentialProvider,
+ OssProfileCredentialProvider,
+};
+use crate::{Credential, constants::*};
+use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
+use reqsign_core::hash::base64_hmac_sha1;
+use reqsign_core::time::Timestamp;
+use reqsign_core::{
+ Context, ProvideCredential, ProvideCredentialChain, ProvideCredentialDyn,
Result,
+};
+use serde::Deserialize;
+use std::collections::{BTreeMap, HashMap};
+use std::sync::Arc;
+use std::sync::atomic::{AtomicU64, Ordering};
+
+static ALIYUN_RPC_QUERY_ENCODE_SET: AsciiSet = NON_ALPHANUMERIC
+ .remove(b'-')
+ .remove(b'.')
+ .remove(b'_')
+ .remove(b'~');
+static SIGNATURE_NONCE_COUNTER: AtomicU64 = AtomicU64::new(0);
+
+/// AssumeRoleCredentialProvider loads credentials via Alibaba Cloud STS
AssumeRole.
+///
+/// `new()` reads role configuration from environment variables at runtime and
+/// resolves the base access key credential from the default static chain:
+/// env -> OSS profile -> shared credentials file -> config file.
+///
+/// Use `with_base_provider(...)` to make the base credential source explicit
and
+/// to avoid depending on the default static chain.
+#[derive(Debug, Clone)]
+pub struct AssumeRoleCredentialProvider {
+ base_provider: Arc<dyn ProvideCredentialDyn<Credential = Credential>>,
+ uses_default_base_provider: bool,
+ role_arn: Option<String>,
+ role_session_name: Option<String>,
+ external_id: Option<String>,
+ sts_endpoint: Option<String>,
+ #[cfg(test)]
+ time: Option<Timestamp>,
+ #[cfg(test)]
+ signature_nonce: Option<String>,
+}
+
+impl Default for AssumeRoleCredentialProvider {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl AssumeRoleCredentialProvider {
+ /// Create a new `AssumeRoleCredentialProvider` instance.
+ ///
+ /// This provider reads role configuration from environment variables at
+ /// runtime. The base access key credential is loaded from the default
+ /// static chain unless overridden with `with_base_provider(...)`.
+ pub fn new() -> Self {
+ Self {
+ base_provider: Arc::new(default_base_provider_chain()),
+ uses_default_base_provider: true,
+ role_arn: None,
+ role_session_name: None,
+ external_id: None,
+ sts_endpoint: None,
+ #[cfg(test)]
+ time: None,
+ #[cfg(test)]
+ signature_nonce: None,
+ }
+ }
+
+ /// Set the base credential provider used to call STS.
+ ///
+ /// This source must yield an access key ID and access key secret. When
set,
+ /// the provider no longer depends on the default static chain.
+ pub fn with_base_provider(
+ mut self,
+ provider: impl ProvideCredential<Credential = Credential> + 'static,
+ ) -> Self {
+ self.base_provider = Arc::new(provider);
+ self.uses_default_base_provider = false;
+ self
+ }
+
+ /// Set the role ARN.
+ ///
+ /// This setting takes precedence over `ALIBABA_CLOUD_ROLE_ARN`.
+ pub fn with_role_arn(mut self, role_arn: impl Into<String>) -> Self {
+ self.role_arn = Some(role_arn.into());
+ self
+ }
+
+ /// Set the role session name.
+ ///
+ /// This setting takes precedence over `ALIBABA_CLOUD_ROLE_SESSION_NAME`.
+ pub fn with_role_session_name(mut self, name: impl Into<String>) -> Self {
+ self.role_session_name = Some(name.into());
+ self
+ }
+
+ /// Set the external ID.
+ ///
+ /// This setting takes precedence over `ALIBABA_CLOUD_EXTERNAL_ID`.
+ pub fn with_external_id(mut self, external_id: impl Into<String>) -> Self {
+ self.external_id = Some(external_id.into());
+ self
+ }
+
+ /// Set the STS endpoint.
+ ///
+ /// This setting takes precedence over `ALIBABA_CLOUD_STS_ENDPOINT`.
+ pub fn with_sts_endpoint(mut self, endpoint: impl Into<String>) -> Self {
+ self.sts_endpoint = Some(endpoint.into());
+ self
+ }
+
+ pub(crate) fn with_default_base_provider(
+ mut self,
+ provider: impl ProvideCredential<Credential = Credential> + 'static,
+ ) -> Self {
+ if self.uses_default_base_provider {
+ self.base_provider = Arc::new(provider);
+ }
+ self
+ }
+
+ #[cfg(test)]
+ fn with_time(mut self, time: Timestamp) -> Self {
+ self.time = Some(time);
+ self
+ }
+
+ #[cfg(test)]
+ fn with_signature_nonce(mut self, nonce: impl Into<String>) -> Self {
+ self.signature_nonce = Some(nonce.into());
+ self
+ }
+
+ fn get_role_arn(&self, envs: &HashMap<String, String>) -> Option<String> {
+ self.role_arn
+ .clone()
+ .or_else(|| envs.get(ALIBABA_CLOUD_ROLE_ARN).cloned())
+ }
+
+ fn get_role_session_name(&self, envs: &HashMap<String, String>) -> String {
+ self.role_session_name
+ .clone()
+ .or_else(|| envs.get(ALIBABA_CLOUD_ROLE_SESSION_NAME).cloned())
+ .unwrap_or_else(|| "reqsign".to_string())
+ }
+
+ fn get_external_id(&self, envs: &HashMap<String, String>) ->
Option<String> {
+ self.external_id
+ .clone()
+ .or_else(|| envs.get(ALIBABA_CLOUD_EXTERNAL_ID).cloned())
+ }
+
+ fn get_sts_endpoint(&self, envs: &HashMap<String, String>) -> String {
+ if let Some(endpoint) = &self.sts_endpoint {
+ return endpoint.clone();
+ }
+
+ match envs.get(ALIBABA_CLOUD_STS_ENDPOINT) {
+ Some(endpoint) => format!("https://{endpoint}"),
+ None => "https://sts.aliyuncs.com".to_string(),
+ }
+ }
+
+ fn get_time(&self) -> Timestamp {
+ #[cfg(test)]
+ if let Some(time) = self.time {
+ return time;
+ }
+
+ Timestamp::now()
+ }
+
+ fn get_signature_nonce(&self, signing_time: Timestamp) -> String {
+ #[cfg(test)]
+ if let Some(nonce) = &self.signature_nonce {
+ return nonce.clone();
+ }
+
+ let counter = SIGNATURE_NONCE_COUNTER.fetch_add(1, Ordering::Relaxed);
+ format!(
+ "{}-{}-{counter}",
+ signing_time.as_second(),
+ signing_time.subsec_nanosecond()
+ )
+ }
+}
+
+impl ProvideCredential for AssumeRoleCredentialProvider {
+ type Credential = Credential;
+
+ async fn provide_credential(&self, ctx: &Context) ->
Result<Option<Self::Credential>> {
+ let envs = ctx.env_vars();
+
+ let Some(role_arn) = self.get_role_arn(&envs) else {
+ return Ok(None);
+ };
+
+ let Some(base_credential) =
self.base_provider.provide_credential_dyn(ctx).await? else {
+ return Ok(None);
+ };
+ if base_credential.access_key_id.is_empty() ||
base_credential.access_key_secret.is_empty()
+ {
+ return Ok(None);
+ }
+
+ let signing_time = self.get_time();
+ let signature_nonce = self.get_signature_nonce(signing_time);
+ let role_session_name = self.get_role_session_name(&envs);
+
+ let mut params = BTreeMap::new();
+ params.insert(
+ "AccessKeyId".to_string(),
+ base_credential.access_key_id.clone(),
+ );
+ params.insert("Action".to_string(), "AssumeRole".to_string());
+ params.insert("Format".to_string(), "JSON".to_string());
+ params.insert("RoleArn".to_string(), role_arn);
+ params.insert("RoleSessionName".to_string(), role_session_name);
+ params.insert("SignatureMethod".to_string(), "HMAC-SHA1".to_string());
+ params.insert("SignatureNonce".to_string(), signature_nonce);
+ params.insert("SignatureVersion".to_string(), "1.0".to_string());
+ params.insert("Timestamp".to_string(),
signing_time.format_rfc3339_zulu());
+ params.insert("Version".to_string(), "2015-04-01".to_string());
+
+ if let Some(external_id) = self.get_external_id(&envs) {
+ params.insert("ExternalId".to_string(), external_id);
+ }
+ if let Some(token) = &base_credential.security_token {
+ params.insert("SecurityToken".to_string(), token.clone());
+ }
+
+ let canonicalized_query_string = canonicalized_query_string(¶ms);
+ let string_to_sign = format!(
+ "GET&%2F&{}",
+ percent_encode_query_value(&canonicalized_query_string)
+ );
+ let signature = base64_hmac_sha1(
+ format!("{}&", base_credential.access_key_secret).as_bytes(),
+ string_to_sign.as_bytes(),
+ );
+
+ let url = format!(
+ "{}/?{}&Signature={}",
+ self.get_sts_endpoint(&envs),
+ canonicalized_query_string,
+ percent_encode_query_value(&signature)
+ );
+
+ let req = http::Request::builder()
+ .method(http::Method::GET)
+ .uri(&url)
+ .header(
+ http::header::CONTENT_TYPE,
+ "application/x-www-form-urlencoded",
+ )
+ .body(Vec::new())?;
+
+ let resp = ctx.http_send(req.map(Into::into)).await?;
+ if resp.status() != http::StatusCode::OK {
+ let content = String::from_utf8_lossy(resp.body());
+ return Err(reqsign_core::Error::unexpected(format!(
+ "request to Aliyun STS Services failed: {content}"
+ )));
+ }
+
+ let resp: AssumeRoleResponse =
serde_json::from_slice(resp.body()).map_err(|e| {
+ reqsign_core::Error::unexpected(format!("Failed to parse STS
response: {e}"))
+ })?;
+ let resp_cred = resp.credentials;
+
+ Ok(Some(Credential {
+ access_key_id: resp_cred.access_key_id,
+ access_key_secret: resp_cred.access_key_secret,
+ security_token: Some(resp_cred.security_token),
+ expires_in: Some(resp_cred.expiration.parse()?),
+ }))
+ }
+}
+
+fn default_base_provider_chain() -> ProvideCredentialChain<Credential> {
+ ProvideCredentialChain::new()
+ .push(EnvCredentialProvider::new())
+ .push(OssProfileCredentialProvider::new())
+ .push(CredentialsFileCredentialProvider::new())
+ .push(ConfigFileCredentialProvider::new())
+}
+
+fn canonicalized_query_string(params: &BTreeMap<String, String>) -> String {
+ params
+ .iter()
+ .map(|(key, value)| {
+ format!(
+ "{}={}",
+ percent_encode_query_value(key),
+ percent_encode_query_value(value)
+ )
+ })
+ .collect::<Vec<_>>()
+ .join("&")
+}
+
+fn percent_encode_query_value(value: &str) -> String {
+ utf8_percent_encode(value, &ALIYUN_RPC_QUERY_ENCODE_SET).to_string()
+}
+
+#[derive(Default, Debug, Deserialize)]
+#[serde(default)]
+struct AssumeRoleResponse {
+ #[serde(rename = "Credentials")]
+ credentials: AssumeRoleCredentials,
+}
+
+#[derive(Default, Debug, Deserialize)]
+#[serde(default, rename_all = "PascalCase")]
+struct AssumeRoleCredentials {
+ access_key_id: String,
+ access_key_secret: String,
+ security_token: String,
+ expiration: String,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::RequestSigner;
+ use bytes::Bytes;
+ use reqsign_core::{Context, HttpSend, Signer, StaticEnv};
+ use std::collections::HashMap;
+ use std::sync::atomic::{AtomicUsize, Ordering};
+ use std::sync::{Arc, Mutex};
+
+ #[derive(Debug, Clone)]
+ struct TestBaseCredentialProvider {
+ credential: Option<Credential>,
+ }
+
+ impl TestBaseCredentialProvider {
+ fn new(credential: Option<Credential>) -> Self {
+ Self { credential }
+ }
+ }
+
+ impl ProvideCredential for TestBaseCredentialProvider {
+ type Credential = Credential;
+
+ async fn provide_credential(&self, _ctx: &Context) ->
Result<Option<Self::Credential>> {
+ Ok(self.credential.clone())
+ }
+ }
+
+ #[derive(Clone, Debug)]
+ struct CaptureHttpSend {
+ uri: Arc<Mutex<Option<String>>>,
+ bodies: Arc<Vec<Vec<u8>>>,
+ calls: Arc<AtomicUsize>,
+ }
+
+ impl CaptureHttpSend {
+ fn new(bodies: Vec<Vec<u8>>) -> Self {
+ Self {
+ uri: Arc::new(Mutex::new(None)),
+ bodies: Arc::new(bodies),
+ calls: Arc::new(AtomicUsize::new(0)),
+ }
+ }
+
+ fn uri(&self) -> Option<String> {
+ self.uri.lock().unwrap().clone()
+ }
+
+ fn calls(&self) -> usize {
+ self.calls.load(Ordering::SeqCst)
+ }
+ }
+
+ impl HttpSend for CaptureHttpSend {
+ async fn http_send(
+ &self,
+ req: http::Request<Bytes>,
+ ) -> reqsign_core::Result<http::Response<Bytes>> {
+ let index = self.calls.fetch_add(1, Ordering::SeqCst);
+ *self.uri.lock().unwrap() = Some(req.uri().to_string());
+ let body = self
+ .bodies
+ .get(index)
+ .cloned()
+ .or_else(|| self.bodies.last().cloned())
+ .unwrap_or_default();
+
+ Ok(http::Response::builder()
+ .status(http::StatusCode::OK)
+ .body(Bytes::from(body))
+ .expect("response must build"))
+ }
+ }
+
+ #[test]
+ fn test_parse_assume_role_response() -> Result<()> {
+ let content = r#"{
+ "RequestId": "3D57EAD2-8723-1F26-B69C-F8707D8B565D",
+ "AssumedRoleUser": {
+ "AssumedRoleId": "33157794895460****",
+ "Arn": "acs:ram::113511544585****:role/test-role/test-session"
+ },
+ "Credentials": {
+ "SecurityToken": "CAIShwJ1q6Ft5B2yfSjIr5bSEsj4g7BihPWGWHz****",
+ "Expiration": "2021-10-20T04:27:09Z",
+ "AccessKeySecret": "CVwjCkNzTMupZ8NbTCxCBRq3K16jtcWFTJAyBEv2****",
+ "AccessKeyId": "STS.NUgYrLnoC37mZZCNnAbez****"
+ }
+}"#;
+
+ let resp: AssumeRoleResponse =
+ serde_json::from_str(content).expect("json deserialize must
succeed");
+
+ assert_eq!(
+ resp.credentials.access_key_id,
+ "STS.NUgYrLnoC37mZZCNnAbez****"
+ );
+ assert_eq!(
+ resp.credentials.access_key_secret,
+ "CVwjCkNzTMupZ8NbTCxCBRq3K16jtcWFTJAyBEv2****"
+ );
+ assert_eq!(
+ resp.credentials.security_token,
+ "CAIShwJ1q6Ft5B2yfSjIr5bSEsj4g7BihPWGWHz****"
+ );
+ assert_eq!(resp.credentials.expiration, "2021-10-20T04:27:09Z");
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_assume_role_loader_without_config() {
+ let ctx = Context::new().with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::new(),
+ });
+
+ let provider = AssumeRoleCredentialProvider::new();
+ let credential = provider.provide_credential(&ctx).await.unwrap();
+
+ assert!(credential.is_none());
+ }
+
+ #[tokio::test]
+ async fn test_assume_role_signs_query_with_explicit_base_provider() ->
Result<()> {
+ let base_credential = Credential {
+ access_key_id: "base-ak".to_string(),
+ access_key_secret: "base-sk".to_string(),
+ security_token: Some("base-token".to_string()),
+ expires_in: None,
+ };
+ let http_send = CaptureHttpSend::new(vec![
+
br#"{"Credentials":{"SecurityToken":"sts-token","Expiration":"2124-05-25T11:45:17Z","AccessKeySecret":"sts-secret","AccessKeyId":"sts-ak"}}"#
+ .to_vec(),
+ ]);
+ let ctx = Context::new()
+ .with_http_send(http_send.clone())
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::new(),
+ });
+
+ let signing_time: Timestamp = "2024-03-05T06:07:08Z".parse().unwrap();
+ let provider = AssumeRoleCredentialProvider::new()
+ .with_base_provider(TestBaseCredentialProvider::new(Some(
+ base_credential.clone(),
+ )))
+ .with_role_arn("acs:ram::123456789012:role/test-role")
+ .with_role_session_name("test-session")
+ .with_external_id("external-id")
+ .with_sts_endpoint("https://sts.example.com")
+ .with_time(signing_time)
+ .with_signature_nonce("test-nonce");
+
+ let credential = provider.provide_credential(&ctx).await?.unwrap();
+ assert_eq!("sts-ak", credential.access_key_id);
+ assert_eq!("sts-secret", credential.access_key_secret);
+ assert_eq!(Some("sts-token".to_string()), credential.security_token);
+
+ let recorded_uri = http_send.uri().expect("request uri must be
captured");
+ let uri: http::Uri = recorded_uri.parse().expect("uri must parse");
+ assert_eq!(
+ "https://sts.example.com/",
+ format!("https://{}{}", uri.authority().unwrap(), uri.path())
+ );
+ let query = uri.query().expect("query must exist");
+ let params: HashMap<String, String> =
form_urlencoded::parse(query.as_bytes())
+ .into_owned()
+ .collect();
+
+ assert_eq!(
+ Some("base-ak"),
+ params.get("AccessKeyId").map(String::as_str)
+ );
+ assert_eq!(Some("AssumeRole"),
params.get("Action").map(String::as_str));
+ assert_eq!(
+ Some("acs:ram::123456789012:role/test-role"),
+ params.get("RoleArn").map(String::as_str)
+ );
+ assert_eq!(
+ Some("test-session"),
+ params.get("RoleSessionName").map(String::as_str)
+ );
+ assert_eq!(
+ Some("external-id"),
+ params.get("ExternalId").map(String::as_str)
+ );
+ assert_eq!(
+ Some("base-token"),
+ params.get("SecurityToken").map(String::as_str)
+ );
+ assert_eq!(
+ Some("2024-03-05T06:07:08Z"),
+ params.get("Timestamp").map(String::as_str)
+ );
+ assert_eq!(
+ Some("test-nonce"),
+ params.get("SignatureNonce").map(String::as_str)
+ );
+
+ let mut expected_params = BTreeMap::new();
+ expected_params.insert("AccessKeyId".to_string(),
"base-ak".to_string());
+ expected_params.insert("Action".to_string(), "AssumeRole".to_string());
+ expected_params.insert("ExternalId".to_string(),
"external-id".to_string());
+ expected_params.insert("Format".to_string(), "JSON".to_string());
+ expected_params.insert(
+ "RoleArn".to_string(),
+ "acs:ram::123456789012:role/test-role".to_string(),
+ );
+ expected_params.insert("RoleSessionName".to_string(),
"test-session".to_string());
+ expected_params.insert("SecurityToken".to_string(),
"base-token".to_string());
+ expected_params.insert("SignatureMethod".to_string(),
"HMAC-SHA1".to_string());
+ expected_params.insert("SignatureNonce".to_string(),
"test-nonce".to_string());
+ expected_params.insert("SignatureVersion".to_string(),
"1.0".to_string());
+ expected_params.insert("Timestamp".to_string(),
"2024-03-05T06:07:08Z".to_string());
+ expected_params.insert("Version".to_string(),
"2015-04-01".to_string());
+
+ let canonicalized = canonicalized_query_string(&expected_params);
+ let string_to_sign = format!("GET&%2F&{}",
percent_encode_query_value(&canonicalized));
+ let expected_signature = base64_hmac_sha1(
+ format!("{}&", base_credential.access_key_secret).as_bytes(),
+ string_to_sign.as_bytes(),
+ );
+ assert_eq!(
+ Some(expected_signature.as_str()),
+ params.get("Signature").map(String::as_str)
+ );
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_assume_role_refreshes_expiring_credential() -> Result<()> {
+ let http_send = CaptureHttpSend::new(vec![
+
br#"{"Credentials":{"SecurityToken":"sts-token-1","Expiration":"2024-03-05T06:08:00Z","AccessKeySecret":"sts-secret-1","AccessKeyId":"sts-ak-1"}}"#
+ .to_vec(),
+
br#"{"Credentials":{"SecurityToken":"sts-token-2","Expiration":"2124-03-05T06:09:00Z","AccessKeySecret":"sts-secret-2","AccessKeyId":"sts-ak-2"}}"#
+ .to_vec(),
+ ]);
+ let ctx = Context::new()
+ .with_http_send(http_send.clone())
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::new(),
+ });
+
+ let provider = AssumeRoleCredentialProvider::new()
+
.with_base_provider(TestBaseCredentialProvider::new(Some(Credential {
+ access_key_id: "base-ak".to_string(),
+ access_key_secret: "base-sk".to_string(),
+ security_token: None,
+ expires_in: None,
+ })))
+ .with_role_arn("acs:ram::123456789012:role/test-role")
+ .with_role_session_name("test-session")
+ .with_sts_endpoint("https://sts.example.com")
+ .with_time("2024-03-05T06:07:08Z".parse().unwrap())
+ .with_signature_nonce("test-nonce");
+ let signer = Signer::new(ctx, provider,
RequestSigner::new("test-bucket"));
+
+ let mut first_req =
+
http::Request::get("https://test-bucket.oss-cn-beijing.aliyuncs.com/object")
+ .body(())
+ .unwrap()
+ .into_parts()
+ .0;
+ signer.sign(&mut first_req, None).await?;
+ assert!(
+ first_req
+ .headers
+ .get(http::header::AUTHORIZATION)
+ .and_then(|v| v.to_str().ok())
+ .expect("authorization must exist")
+ .starts_with("OSS sts-ak-1:")
+ );
+
+ let mut second_req =
+
http::Request::get("https://test-bucket.oss-cn-beijing.aliyuncs.com/object")
+ .body(())
+ .unwrap()
+ .into_parts()
+ .0;
+ signer.sign(&mut second_req, None).await?;
+
+ let authorization = second_req
+ .headers
+ .get(http::header::AUTHORIZATION)
+ .and_then(|v| v.to_str().ok())
+ .expect("authorization must exist");
+ assert!(authorization.starts_with("OSS sts-ak-2:"));
+ assert_eq!(2, http_send.calls());
+
+ Ok(())
+ }
+}
diff --git a/services/aliyun-oss/src/provide_credential/default.rs
b/services/aliyun-oss/src/provide_credential/default.rs
index 41c581f..9e8b1c0 100644
--- a/services/aliyun-oss/src/provide_credential/default.rs
+++ b/services/aliyun-oss/src/provide_credential/default.rs
@@ -17,8 +17,9 @@
use crate::Credential;
use crate::provide_credential::{
- AssumeRoleWithOidcCredentialProvider, ConfigFileCredentialProvider,
- CredentialsFileCredentialProvider, EnvCredentialProvider,
OssProfileCredentialProvider,
+ AssumeRoleCredentialProvider, AssumeRoleWithOidcCredentialProvider,
+ ConfigFileCredentialProvider, CredentialsFileCredentialProvider,
EnvCredentialProvider,
+ OssProfileCredentialProvider,
};
use reqsign_core::{Context, ProvideCredential, ProvideCredentialChain, Result};
@@ -26,11 +27,12 @@ use reqsign_core::{Context, ProvideCredential,
ProvideCredentialChain, Result};
///
/// Resolution order:
///
-/// 1. Environment variables
-/// 2. OSS profile file
-/// 3. Alibaba shared credentials file
-/// 4. Alibaba CLI config file
-/// 5. Assume Role with OIDC
+/// 1. AssumeRole via base AK credentials
+/// 2. Environment variables
+/// 3. OSS profile file
+/// 4. Alibaba shared credentials file
+/// 5. Alibaba CLI config file
+/// 6. Assume Role with OIDC
#[derive(Debug)]
pub struct DefaultCredentialProvider {
chain: ProvideCredentialChain<Credential>,
@@ -85,6 +87,7 @@ impl DefaultCredentialProvider {
/// Use `slot(provider)` to override a default provider or `no_slot()` to
/// remove it from the chain before calling `build()`.
pub struct DefaultCredentialProviderBuilder {
+ assume_role: Option<AssumeRoleCredentialProvider>,
env: Option<EnvCredentialProvider>,
oss_profile: Option<OssProfileCredentialProvider>,
credentials_file: Option<CredentialsFileCredentialProvider>,
@@ -95,6 +98,7 @@ pub struct DefaultCredentialProviderBuilder {
impl Default for DefaultCredentialProviderBuilder {
fn default() -> Self {
Self {
+ assume_role: Some(AssumeRoleCredentialProvider::new()),
env: Some(EnvCredentialProvider::new()),
oss_profile: Some(OssProfileCredentialProvider::new()),
credentials_file: Some(CredentialsFileCredentialProvider::new()),
@@ -110,6 +114,18 @@ impl DefaultCredentialProviderBuilder {
Self::default()
}
+ /// Set the AssumeRole credential provider slot.
+ pub fn assume_role(mut self, provider: AssumeRoleCredentialProvider) ->
Self {
+ self.assume_role = Some(provider);
+ self
+ }
+
+ /// Remove the AssumeRole credential provider slot.
+ pub fn no_assume_role(mut self) -> Self {
+ self.assume_role = None;
+ self
+ }
+
/// Set the environment credential provider slot.
pub fn env(mut self, provider: EnvCredentialProvider) -> Self {
self.env = Some(provider);
@@ -157,6 +173,7 @@ impl DefaultCredentialProviderBuilder {
self.config_file = None;
self
}
+
/// Set the OIDC credential provider slot.
pub fn oidc(mut self, provider: AssumeRoleWithOidcCredentialProvider) ->
Self {
self.oidc = Some(provider);
@@ -171,7 +188,15 @@ impl DefaultCredentialProviderBuilder {
/// Build the `DefaultCredentialProvider` with the configured options.
pub fn build(self) -> DefaultCredentialProvider {
+ let assume_role_base_chain = ProvideCredentialChain::new()
+ .push_opt(self.env.clone())
+ .push_opt(self.oss_profile.clone())
+ .push_opt(self.credentials_file.clone())
+ .push_opt(self.config_file.clone());
let mut chain = ProvideCredentialChain::new();
+ if let Some(p) = self.assume_role {
+ chain =
chain.push(p.with_default_base_provider(assume_role_base_chain));
+ }
if let Some(p) = self.env {
chain = chain.push(p);
}
@@ -190,6 +215,25 @@ impl DefaultCredentialProviderBuilder {
DefaultCredentialProvider::with_chain(chain)
}
}
+
+trait PushOptionalProvider {
+ fn push_opt<P>(self, provider: Option<P>) -> Self
+ where
+ P: ProvideCredential<Credential = Credential> + 'static;
+}
+
+impl PushOptionalProvider for ProvideCredentialChain<Credential> {
+ fn push_opt<P>(self, provider: Option<P>) -> Self
+ where
+ P: ProvideCredential<Credential = Credential> + 'static,
+ {
+ match provider {
+ Some(provider) => self.push(provider),
+ None => self,
+ }
+ }
+}
+
impl ProvideCredential for DefaultCredentialProvider {
type Credential = Credential;
@@ -386,6 +430,7 @@ access_key_secret = profile_secret_key
});
let credential = DefaultCredentialProvider::builder()
+ .no_assume_role()
.no_oidc()
.build()
.provide_credential(&ctx)
@@ -396,6 +441,7 @@ access_key_secret = profile_secret_key
assert_eq!(0, file_read.calls());
let credential = DefaultCredentialProvider::builder()
+ .no_assume_role()
.no_env()
.no_oidc()
.build()
@@ -408,6 +454,7 @@ access_key_secret = profile_secret_key
assert_eq!(1, file_read.calls());
let credential = DefaultCredentialProvider::builder()
+ .no_assume_role()
.no_env()
.no_oss_profile()
.no_oidc()
@@ -460,7 +507,9 @@ access_key_secret = profile_secret_key
]),
});
- let credential = DefaultCredentialProvider::new()
+ let credential = DefaultCredentialProvider::builder()
+ .no_assume_role()
+ .build()
.provide_credential(&ctx)
.await
.unwrap()
@@ -517,6 +566,7 @@ access_key_secret=shared_secret_key
});
let credential = DefaultCredentialProvider::builder()
+ .no_assume_role()
.no_env()
.no_oss_profile()
.no_oidc()
@@ -558,6 +608,7 @@ access_key_secret=shared_secret_key
});
let credential = DefaultCredentialProvider::builder()
+ .no_assume_role()
.no_env()
.no_oss_profile()
.no_credentials_file()
@@ -600,6 +651,7 @@ access_key_secret=shared_secret_key
});
let credential = DefaultCredentialProvider::builder()
+ .no_assume_role()
.no_env()
.no_oss_profile()
.no_credentials_file()
@@ -612,6 +664,7 @@ access_key_secret=shared_secret_key
assert_eq!("config_access_key", credential.access_key_id);
let credential = DefaultCredentialProvider::builder()
+ .no_assume_role()
.no_env()
.no_oss_profile()
.no_credentials_file()
@@ -673,6 +726,7 @@ access_key_secret=shared_secret_key
});
let credential = DefaultCredentialProvider::builder()
+ .no_assume_role()
.no_env()
.no_oss_profile()
.no_credentials_file()
@@ -719,6 +773,7 @@ access_key_secret=shared_secret_key
});
let credential = DefaultCredentialProvider::builder()
+ .no_assume_role()
.no_env()
.no_oss_profile()
.oidc(AssumeRoleWithOidcCredentialProvider::new())
@@ -730,14 +785,139 @@ access_key_secret=shared_secret_key
assert_eq!(1, file_read.calls());
assert_eq!(1, http_send.calls());
+ let credential = DefaultCredentialProvider::builder()
+ .no_assume_role()
+ .no_env()
+ .no_oss_profile()
+ .no_oidc()
+ .build()
+ .provide_credential(&ctx)
+ .await
+ .unwrap();
+ assert!(credential.is_none());
+ }
+
+ #[tokio::test]
+ async fn
test_default_loader_prefers_assume_role_over_raw_env_credentials() {
+ 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())
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from_iter([
+ (
+ ALIBABA_CLOUD_ACCESS_KEY_ID.to_string(),
+ "base_access_key".to_string(),
+ ),
+ (
+ ALIBABA_CLOUD_ACCESS_KEY_SECRET.to_string(),
+ "base_secret_key".to_string(),
+ ),
+ (
+ ALIBABA_CLOUD_ROLE_ARN.to_string(),
+ "acs:ram::123456789012:role/test-role".to_string(),
+ ),
+ ]),
+ });
+
+ let credential = DefaultCredentialProvider::builder()
+ .no_oidc()
+ .build()
+ .provide_credential(&ctx)
+ .await
+ .unwrap()
+ .unwrap();
+
+ assert_eq!("assumed_access_key", credential.access_key_id);
+ assert_eq!("assumed_secret_key", credential.access_key_secret);
+ assert_eq!(Some("sts_token".to_string()), credential.security_token);
+ assert_eq!(1, http_send.calls());
+ }
+
+ #[tokio::test]
+ async fn test_builder_no_env_removes_env_from_assume_role_base_chain() {
+ 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())
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from_iter([
+ (
+ ALIBABA_CLOUD_ACCESS_KEY_ID.to_string(),
+ "base_access_key".to_string(),
+ ),
+ (
+ ALIBABA_CLOUD_ACCESS_KEY_SECRET.to_string(),
+ "base_secret_key".to_string(),
+ ),
+ (
+ ALIBABA_CLOUD_ROLE_ARN.to_string(),
+ "acs:ram::123456789012:role/test-role".to_string(),
+ ),
+ ]),
+ });
+
let credential = DefaultCredentialProvider::builder()
.no_env()
.no_oss_profile()
+ .no_credentials_file()
+ .no_config_file()
.no_oidc()
.build()
.provide_credential(&ctx)
.await
.unwrap();
+
assert!(credential.is_none());
+ assert_eq!(0, http_send.calls());
+ }
+
+ #[tokio::test]
+ async fn test_builder_no_assume_role_removes_assume_role_provider() {
+ 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())
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from_iter([
+ (
+ ALIBABA_CLOUD_ACCESS_KEY_ID.to_string(),
+ "base_access_key".to_string(),
+ ),
+ (
+ ALIBABA_CLOUD_ACCESS_KEY_SECRET.to_string(),
+ "base_secret_key".to_string(),
+ ),
+ (
+ ALIBABA_CLOUD_ROLE_ARN.to_string(),
+ "acs:ram::123456789012:role/test-role".to_string(),
+ ),
+ ]),
+ });
+
+ let credential = DefaultCredentialProvider::builder()
+ .no_assume_role()
+ .no_oidc()
+ .build()
+ .provide_credential(&ctx)
+ .await
+ .unwrap()
+ .unwrap();
+
+ assert_eq!("base_access_key", credential.access_key_id);
+ assert_eq!("base_secret_key", credential.access_key_secret);
+ assert_eq!(0, http_send.calls());
}
}
diff --git a/services/aliyun-oss/src/provide_credential/mod.rs
b/services/aliyun-oss/src/provide_credential/mod.rs
index 92a6704..48b5dff 100644
--- a/services/aliyun-oss/src/provide_credential/mod.rs
+++ b/services/aliyun-oss/src/provide_credential/mod.rs
@@ -15,6 +15,9 @@
// specific language governing permissions and limitations
// under the License.
+mod assume_role;
+pub use assume_role::AssumeRoleCredentialProvider;
+
mod assume_role_with_oidc;
pub use assume_role_with_oidc::AssumeRoleWithOidcCredentialProvider;