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/08/27 06:01:50 UTC

(opendal-reqsign) 01/01: feat(google): preserve signer identity in token credentials

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

Xuanwo pushed a commit to branch xuanwo/google-signer-identity
in repository https://gitbox.apache.org/repos/asf/opendal-reqsign.git

commit f4fd1211b9a44f66c22b34ff4984a0ff04e7b208
Author: Xuanwo <gi...@xuanwo.io>
AuthorDate: Thu Aug 27 14:01:24 2026 +0800

    feat(google): preserve signer identity in token credentials
---
 services/google/README.md                          |   6 ++
 services/google/src/credential.rs                  |  66 +++++++++++-
 .../src/credential_access_boundary/client_side.rs  |   1 +
 .../src/provide_credential/external_account.rs     |  50 +++------
 .../impersonated_service_account.rs                |  80 +++++++++++++-
 .../google/src/provide_credential/vm_metadata.rs   |  18 +++-
 services/google/src/sign_request.rs                | 115 +++++++++++++++++++--
 7 files changed, 285 insertions(+), 51 deletions(-)

diff --git a/services/google/README.md b/services/google/README.md
index 6bfcdcb..327f647 100644
--- a/services/google/README.md
+++ b/services/google/README.md
@@ -31,6 +31,12 @@ The crate also supports server-side Cloud Storage Credential Access Boundary
 downscoping. Enable the `credential-access-boundary-client-side` feature for
 local client-side token generation.
 
+For query signing, credential providers preserve a target service account email
+when they can determine it from impersonation or VM metadata configuration. The
+request signer uses that identity with IAMCredentials `signBlob`. An email set
+with `RequestSigner::with_signer_email` takes precedence over the
+provider-discovered identity.
+
 ## Examples
 
 - [Credential-chain logging](examples/chain_logging.rs)
diff --git a/services/google/src/credential.rs b/services/google/src/credential.rs
index 42440ce..13bc413 100644
--- a/services/google/src/credential.rs
+++ b/services/google/src/credential.rs
@@ -259,13 +259,15 @@ impl KeyTrait for Token {
     }
 }
 
-/// Credential represents Google credentials that may contain both service account and token.
+/// Credential represents Google credentials that may contain a service account, token, and
+/// provider-discovered signer identity.
 ///
 /// **IMPORTANT**: This is a specially designed structure that can hold both ServiceAccount
 /// and Token simultaneously. This design is intentional and critical for Google's authentication:
 ///
 /// - Service account only: Used for signed URL generation and JWT-based authentication
-/// - Token only: Used for Bearer authentication (e.g., from metadata server, OAuth2)  
+/// - Token only: Used for Bearer authentication (e.g., from metadata server, OAuth2)
+/// - Token with signer email: Also supports query signing through IAMCredentials `signBlob`
 /// - Both: The RequestSigner is responsible for exchanging service account for tokens when needed,
 ///   and can use cached tokens when available to avoid unnecessary exchanges
 ///
@@ -278,6 +280,10 @@ pub struct Credential {
     pub service_account: Option<ServiceAccount>,
     /// OAuth2 access token, if available.
     pub token: Option<Token>,
+    /// Service account email authorized to sign with the token, if known by the provider.
+    ///
+    /// This identity is used only for query signing and does not affect Bearer authentication.
+    pub signer_email: Option<String>,
 }
 
 impl Credential {
@@ -286,17 +292,27 @@ impl Credential {
         Self {
             service_account: Some(service_account),
             token: None,
+            signer_email: None,
         }
     }
 
-    /// Create a credential with only a token.
+    /// Create a credential with a token.
     pub fn with_token(token: Token) -> Self {
         Self {
             service_account: None,
             token: Some(token),
+            signer_email: None,
         }
     }
 
+    /// Set the service account email authorized to sign with this credential's token.
+    ///
+    /// This identity is used only for query signing and does not affect Bearer authentication.
+    pub fn with_signer_email(mut self, signer_email: impl Into<String>) -> Self {
+        self.signer_email = Some(signer_email.into());
+        self
+    }
+
     /// Check if the credential has a service account.
     pub fn has_service_account(&self) -> bool {
         self.service_account.is_some()
@@ -313,6 +329,37 @@ impl Credential {
     }
 }
 
+pub(crate) fn parse_service_account_impersonation_url(url: &str) -> Result<String> {
+    let marker = "/serviceAccounts/";
+    let start = url.find(marker).ok_or_else(|| {
+        reqsign_core::Error::config_invalid(format!(
+            "service_account_impersonation_url missing {marker}: {url}"
+        ))
+    })?;
+    let rest = &url[start + marker.len()..];
+    let end = rest.find(':').ok_or_else(|| {
+        reqsign_core::Error::config_invalid(format!(
+            "service_account_impersonation_url missing action separator: {url}"
+        ))
+    })?;
+
+    let email = percent_encoding::percent_decode_str(&rest[..end])
+        .decode_utf8()
+        .map_err(|e| {
+            reqsign_core::Error::config_invalid(
+                "service_account_impersonation_url contains invalid UTF-8 email",
+            )
+            .with_source(e)
+        })?;
+    if email.is_empty() {
+        return Err(reqsign_core::Error::config_invalid(
+            "service_account_impersonation_url resolved empty service account email",
+        ));
+    }
+
+    Ok(email.into_owned())
+}
+
 impl KeyTrait for Credential {
     fn is_valid(&self) -> bool {
         self.service_account
@@ -387,6 +434,15 @@ mod tests {
         assert!(result.is_err());
     }
 
+    #[test]
+    fn test_parse_service_account_impersonation_url() {
+        let url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/signer%40example.com:generateAccessToken";
+        assert_eq!(
+            parse_service_account_impersonation_url(url).unwrap(),
+            "signer@example.com"
+        );
+    }
+
     #[test]
     fn test_token_is_valid() {
         let mut token = Token {
@@ -565,6 +621,10 @@ mod tests {
         assert!(!cred.has_service_account());
         assert!(cred.has_token());
         assert!(cred.has_valid_token());
+        assert!(cred.signer_email.is_none());
+
+        let cred = cred.with_signer_email("signer@example.com");
+        assert_eq!(cred.signer_email.as_deref(), Some("signer@example.com"));
 
         // Invalid token only
         let cred = Credential::with_token(Token {
diff --git a/services/google/src/credential_access_boundary/client_side.rs b/services/google/src/credential_access_boundary/client_side.rs
index d1654b6..8bbd3d9 100644
--- a/services/google/src/credential_access_boundary/client_side.rs
+++ b/services/google/src/credential_access_boundary/client_side.rs
@@ -1682,6 +1682,7 @@ mod tests {
                     client_email: "service@example.com".to_string(),
                 }),
                 token: Some(valid_token),
+                signer_email: None,
             },
             source_token("", Some(now + Duration::from_secs(2 * 60 * 60))),
             source_token("source", None),
diff --git a/services/google/src/provide_credential/external_account.rs b/services/google/src/provide_credential/external_account.rs
index 0fe447c..7ff69de 100644
--- a/services/google/src/provide_credential/external_account.rs
+++ b/services/google/src/provide_credential/external_account.rs
@@ -28,7 +28,9 @@ use reqsign_aws_v4::{
 };
 use serde::{Deserialize, Serialize};
 
-use crate::credential::{Credential, ExternalAccount, Token, external_account};
+use crate::credential::{
+    Credential, ExternalAccount, Token, external_account, parse_service_account_impersonation_url,
+};
 use reqsign_core::time::Timestamp;
 use reqsign_core::{Context, ProvideCredential, Result, SignRequest};
 
@@ -574,7 +576,7 @@ impl ExternalAccountCredentialProvider {
 
         if let Some(url) = &self.external_account.service_account_impersonation_url {
             let url = resolve_template(ctx, url)?;
-            let email = parse_impersonated_service_account_email(&url)?;
+            let email = parse_service_account_impersonation_url(&url)?;
             envs.insert(
                 GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL.to_string(),
                 email,
@@ -932,6 +934,12 @@ impl ProvideCredential for ExternalAccountCredentialProvider {
     type Credential = Credential;
 
     async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
+        let signer_email = self
+            .external_account
+            .service_account_impersonation_url
+            .as_deref()
+            .and_then(|url| parse_service_account_impersonation_url(url).ok());
+
         // Load OIDC token from source
         let oidc_token = self.load_oidc_token(ctx).await?;
 
@@ -948,7 +956,11 @@ impl ProvideCredential for ExternalAccountCredentialProvider {
             sts_token
         };
 
-        Ok(Some(Credential::with_token(final_token)))
+        let credential = Credential::with_token(final_token);
+        Ok(Some(match signer_email {
+            Some(signer_email) => credential.with_signer_email(signer_email),
+            None => credential,
+        }))
     }
 }
 
@@ -991,37 +1003,6 @@ fn resolve_template(ctx: &Context, input: &str) -> Result<String> {
     }
 }
 
-fn parse_impersonated_service_account_email(url: &str) -> Result<String> {
-    let marker = "/serviceAccounts/";
-    let start = url.find(marker).ok_or_else(|| {
-        reqsign_core::Error::config_invalid(format!(
-            "service_account_impersonation_url missing {marker}: {url}"
-        ))
-    })?;
-    let rest = &url[start + marker.len()..];
-    let end = rest.find(':').ok_or_else(|| {
-        reqsign_core::Error::config_invalid(format!(
-            "service_account_impersonation_url missing action separator: {url}"
-        ))
-    })?;
-
-    let email = percent_encoding::percent_decode_str(&rest[..end])
-        .decode_utf8()
-        .map_err(|e| {
-            reqsign_core::Error::config_invalid(
-                "service_account_impersonation_url contains invalid UTF-8 email",
-            )
-            .with_source(e)
-        })?;
-    if email.is_empty() {
-        return Err(reqsign_core::Error::config_invalid(
-            "service_account_impersonation_url resolved empty service account email",
-        ));
-    }
-
-    Ok(email.into_owned())
-}
-
 struct ResolvedAwsSource {
     region_url: Option<String>,
     url: Option<String>,
@@ -1591,6 +1572,7 @@ mod tests {
             .await?
             .expect("credential must exist");
         assert!(cred.has_valid_token());
+        assert_eq!(cred.signer_email.as_deref(), Some("sa@example.com"));
 
         assert_eq!(
             sts_scope.lock().expect("lock").as_deref(),
diff --git a/services/google/src/provide_credential/impersonated_service_account.rs b/services/google/src/provide_credential/impersonated_service_account.rs
index c5e4725..d42c039 100644
--- a/services/google/src/provide_credential/impersonated_service_account.rs
+++ b/services/google/src/provide_credential/impersonated_service_account.rs
@@ -21,7 +21,9 @@ use http::header::CONTENT_TYPE;
 use log::{debug, error};
 use serde::{Deserialize, Serialize};
 
-use crate::credential::{Credential, ImpersonatedServiceAccount, Token};
+use crate::credential::{
+    Credential, ImpersonatedServiceAccount, Token, parse_service_account_impersonation_url,
+};
 use reqsign_core::time::Timestamp;
 use reqsign_core::{Context, ProvideCredential, Result};
 
@@ -216,6 +218,80 @@ impl ProvideCredential for ImpersonatedServiceAccountCredentialProvider {
         // Then exchange for impersonated access token
         let access_token = self.generate_access_token(ctx, &bearer_token).await?;
 
-        Ok(Some(Credential::with_token(access_token)))
+        let credential = Credential::with_token(access_token);
+        let signer_email = parse_service_account_impersonation_url(
+            &self
+                .impersonated_service_account
+                .service_account_impersonation_url,
+        )
+        .ok();
+        Ok(Some(match signer_email {
+            Some(signer_email) => credential.with_signer_email(signer_email),
+            None => credential,
+        }))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use bytes::Bytes;
+    use reqsign_core::HttpSend;
+
+    #[derive(Clone, Debug)]
+    struct MockHttpSend;
+
+    impl HttpSend for MockHttpSend {
+        async fn http_send(&self, req: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
+            let body = match req.uri().to_string().as_str() {
+                "https://oauth2.googleapis.com/token" => {
+                    br#"{"access_token":"source-token","expires_in":3600}"#.as_slice()
+                }
+                "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/target%40example.com:generateAccessToken" => {
+                    br#"{"accessToken":"impersonated-token","expireTime":"2100-01-01T00:00:00Z"}"#
+                        .as_slice()
+                }
+                uri => panic!("unexpected request: {uri}"),
+            };
+
+            Ok(http::Response::builder()
+                .status(http::StatusCode::OK)
+                .body(body.into())
+                .expect("response must build"))
+        }
+    }
+
+    #[tokio::test]
+    async fn preserves_impersonated_service_account_identity() -> Result<()> {
+        let provider = ImpersonatedServiceAccountCredentialProvider::new(
+            ImpersonatedServiceAccount {
+                service_account_impersonation_url: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/target%40example.com:generateAccessToken".to_string(),
+                source_credentials: crate::credential::OAuth2Credentials {
+                    client_id: "client-id".to_string(),
+                    client_secret: "client-secret".to_string(),
+                    refresh_token: "refresh-token".to_string(),
+                },
+                delegates: Vec::new(),
+            },
+        );
+
+        let credential = provider
+            .provide_credential(&Context::new().with_http_send(MockHttpSend))
+            .await?
+            .expect("credential must exist");
+
+        assert_eq!(
+            credential.signer_email.as_deref(),
+            Some("target@example.com")
+        );
+        assert_eq!(
+            credential
+                .token
+                .as_ref()
+                .expect("token must exist")
+                .access_token,
+            "impersonated-token"
+        );
+        Ok(())
     }
 }
diff --git a/services/google/src/provide_credential/vm_metadata.rs b/services/google/src/provide_credential/vm_metadata.rs
index 57af156..c8f73b3 100644
--- a/services/google/src/provide_credential/vm_metadata.rs
+++ b/services/google/src/provide_credential/vm_metadata.rs
@@ -58,7 +58,8 @@ impl VmMetadataCredentialProvider {
 
     /// Set the service account used to retrieve a token from VM metadata service.
     ///
-    /// Defaults to `default` if not configured.
+    /// Defaults to `default` if not configured. A configured value other than `default` is also
+    /// preserved as the token credential's signer email for query signing.
     pub fn with_service_account(mut self, service_account: impl Into<String>) -> Self {
         self.service_account = Some(service_account.into());
         self
@@ -114,10 +115,16 @@ impl ProvideCredential for VmMetadataCredentialProvider {
             })?;
 
         let expires_at = Timestamp::now() + Duration::from_secs(token_resp.expires_in);
-        Ok(Some(Credential::with_token(Token {
+        let credential = Credential::with_token(Token {
             access_token: token_resp.access_token,
             expires_at: Some(expires_at),
-        })))
+        });
+        Ok(Some(match self.service_account.as_deref() {
+            Some(service_account) if service_account != "default" => {
+                credential.with_signer_email(service_account)
+            }
+            _ => credential,
+        }))
     }
 }
 
@@ -160,6 +167,7 @@ mod tests {
             .expect("credential must exist");
 
         assert!(cred.has_token());
+        assert!(cred.signer_email.is_none());
         assert_eq!(
             http.uris.lock().unwrap().as_slice(),
             &["http://127.0.0.1:8080/computeMetadata/v1/instance/service-accounts/default/token?scopes=https://www.googleapis.com/auth/cloud-platform".to_string()]
@@ -182,6 +190,10 @@ mod tests {
             .expect("credential must exist");
 
         assert!(cred.has_token());
+        assert_eq!(
+            cred.signer_email.as_deref(),
+            Some("custom@test-project.iam.gserviceaccount.com")
+        );
         assert_eq!(
             http.uris.lock().unwrap().as_slice(),
             &["http://127.0.0.1:8080/computeMetadata/v1/instance/service-accounts/custom@test-project.iam.gserviceaccount.com/token?scopes=https://www.googleapis.com/auth/cloud-platform".to_string()]
diff --git a/services/google/src/sign_request.rs b/services/google/src/sign_request.rs
index dbc45a8..750ad05 100644
--- a/services/google/src/sign_request.rs
+++ b/services/google/src/sign_request.rs
@@ -122,8 +122,9 @@ impl RequestSigner {
 
     /// Set the signer service account email used for query signing via IAMCredentials `signBlob`.
     ///
-    /// This is required when generating signed URLs without an embedded service account private key
-    /// (e.g. ADC / WIF / impersonation tokens).
+    /// This explicitly configured email takes precedence over a signer email discovered by the
+    /// credential provider. It is required when generating signed URLs from a token whose provider
+    /// cannot determine the signer identity.
     pub fn with_signer_email(mut self, signer_email: impl Into<String>) -> Self {
         self.signer_email = Some(signer_email.into());
         self
@@ -430,9 +431,12 @@ impl SignRequest for RequestSigner {
                     let (signing_req, uri) =
                         self.build_signed_query_with_service_account(req, sa, expires)?;
                     (signing_req, Some(uri))
-                } else if let (Some(token), Some(signer_email)) =
-                    (cred.token.as_ref(), self.signer_email.as_deref())
-                {
+                } else if let (Some(token), Some(signer_email)) = (
+                    cred.token.as_ref(),
+                    self.signer_email
+                        .as_deref()
+                        .or(cred.signer_email.as_deref()),
+                ) {
                     if !token.is_valid_at(required_until) {
                         return Err(reqsign_core::Error::credential_invalid(
                             "token required for iamcredentials signBlob query signing",
@@ -451,7 +455,7 @@ impl SignRequest for RequestSigner {
                     (signing_req, Some(uri))
                 } else {
                     return Err(reqsign_core::Error::credential_invalid(
-                        "service account or token + signer_email required for query signing",
+                        "service account or token + signer identity required for query signing",
                     ));
                 }
             }
@@ -696,16 +700,37 @@ mod tests {
         payload_b64: Option<String>,
     }
 
-    #[derive(Clone, Debug, Default)]
+    #[derive(Clone, Debug)]
     struct MockHttpSend {
         recorded: Arc<Mutex<Recorded>>,
+        expected_signer_email: String,
     }
+
+    impl Default for MockHttpSend {
+        fn default() -> Self {
+            Self {
+                recorded: Arc::default(),
+                expected_signer_email: "test-signer@example.com".to_string(),
+            }
+        }
+    }
+
+    impl MockHttpSend {
+        fn with_expected_signer_email(mut self, signer_email: &str) -> Self {
+            self.expected_signer_email = signer_email.to_string();
+            self
+        }
+    }
+
     impl HttpSend for MockHttpSend {
         async fn http_send(&self, req: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
             assert_eq!(req.method(), http::Method::POST);
             assert_eq!(
                 req.uri().to_string(),
-                "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-signer@example.com:signBlob"
+                format!(
+                    "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}:signBlob",
+                    self.expected_signer_email
+                )
             );
             assert_eq!(
                 req.headers()
@@ -853,6 +878,77 @@ mod tests {
         Ok(())
     }
 
+    #[tokio::test]
+    async fn query_signing_uses_credential_signer_email() -> Result<()> {
+        let signer_email = "inferred-signer@example.com";
+        let ctx = Context::new()
+            .with_http_send(MockHttpSend::default().with_expected_signer_email(signer_email));
+        let credential = Credential::with_token(Token {
+            access_token: "test-access-token".to_string(),
+            expires_at: Some(Timestamp::now() + Duration::from_secs(60)),
+        })
+        .with_signer_email(signer_email);
+        let mut parts = http::Request::get("https://storage.googleapis.com/bucket/object")
+            .body(())?
+            .into_parts()
+            .0;
+
+        RequestSigner::new("storage")
+            .sign_request(
+                &ctx,
+                &mut parts,
+                Some(&credential),
+                Some(Duration::from_secs(300)),
+            )
+            .await?;
+
+        assert!(
+            query_get(
+                parts.uri.query().expect("signed URL query must exist"),
+                "X-Goog-Credential"
+            )
+            .expect("credential query must exist")
+            .starts_with("inferred-signer%40example.com%2F")
+        );
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn explicit_signer_email_overrides_credential_identity() -> Result<()> {
+        let explicit_email = "explicit-signer@example.com";
+        let ctx = Context::new()
+            .with_http_send(MockHttpSend::default().with_expected_signer_email(explicit_email));
+        let credential = Credential::with_token(Token {
+            access_token: "test-access-token".to_string(),
+            expires_at: Some(Timestamp::now() + Duration::from_secs(60)),
+        })
+        .with_signer_email("inferred-signer@example.com");
+        let mut parts = http::Request::get("https://storage.googleapis.com/bucket/object")
+            .body(())?
+            .into_parts()
+            .0;
+
+        RequestSigner::new("storage")
+            .with_signer_email(explicit_email)
+            .sign_request(
+                &ctx,
+                &mut parts,
+                Some(&credential),
+                Some(Duration::from_secs(300)),
+            )
+            .await?;
+
+        assert!(
+            query_get(
+                parts.uri.query().expect("signed URL query must exist"),
+                "X-Goog-Credential"
+            )
+            .expect("credential query must exist")
+            .starts_with("explicit-signer%40example.com%2F")
+        );
+        Ok(())
+    }
+
     #[tokio::test]
     async fn signer_refreshes_near_expiry_token_without_binding_it_to_signed_url_lifetime()
     -> Result<()> {
@@ -903,7 +999,8 @@ mod tests {
         let credential = Credential::with_token(Token {
             access_token: "test-access-token".to_string(),
             expires_at: None,
-        });
+        })
+        .with_signer_email("signer@example.com");
         let original_uri = format!("https://storage.googleapis.com/bucket/object?{RAW_QUERY}");
         let mut parts = http::Request::get(&original_uri)
             .header("x-custom", " value ")