From 94b9cd6bce9a38f5e3d79246826ac3f84b46c8dd Mon Sep 17 00:00:00 2001 From: pureugong Date: Sun, 22 Mar 2026 18:20:56 +0900 Subject: [PATCH 1/3] feat: restore domain-wide delegation via GOOGLE_WORKSPACE_CLI_IMPERSONATED_USER MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-add service account impersonation support that was removed in PR #253 alongside multi-account cleanup. DWD is the standard mechanism for server-to-server Google Workspace integration — without the `sub` claim, user-scoped APIs (Gmail, Calendar) fail with "Precondition check failed". This is a minimal restoration: only the env var and `builder.subject()` call. Does NOT re-add multi-account, accounts.json, or --account flag. Changes: - Read GOOGLE_WORKSPACE_CLI_IMPERSONATED_USER env var in get_token() - Set builder.subject() on ServiceAccountAuthenticator when present - Show impersonated_user in `gws auth status` output - Add env var to help text and README documentation --- .changeset/restore-dwd-impersonation.md | 5 +++++ README.md | 17 +++++++++++++++++ src/auth.rs | 13 +++++++++++-- src/auth_commands.rs | 7 +++++++ src/main.rs | 1 + 5 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 .changeset/restore-dwd-impersonation.md diff --git a/.changeset/restore-dwd-impersonation.md b/.changeset/restore-dwd-impersonation.md new file mode 100644 index 00000000..6e6d7d5a --- /dev/null +++ b/.changeset/restore-dwd-impersonation.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": minor +--- + +Restore domain-wide delegation support for service accounts via `GOOGLE_WORKSPACE_CLI_IMPERSONATED_USER` env var diff --git a/README.md b/README.md index 14b0fa5b..99e962a5 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,22 @@ export GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=/path/to/service-account.json gws drive files list ``` +#### Domain-Wide Delegation (DWD) + +To access user data (Gmail, Calendar, etc.) via a service account with +[domain-wide delegation](https://developers.google.com/identity/protocols/oauth2/service-account#delegatingauthority), +set the impersonated user: + +```bash +export GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=/path/to/service-account.json +export GOOGLE_WORKSPACE_CLI_IMPERSONATED_USER=user@example.com +gws gmail users messages list --params '{"userId": "me"}' +``` + +> **Note:** Without `GOOGLE_WORKSPACE_CLI_IMPERSONATED_USER`, service accounts +> can only access their own resources. User-scoped APIs like Gmail and Calendar +> require impersonation via DWD. + ### Pre-obtained Access Token Useful when another tool (e.g. `gcloud`) already mints tokens for your environment. @@ -382,6 +398,7 @@ All variables are optional. See [`.env.example`](.env.example) for a copy-paste |---|---| | `GOOGLE_WORKSPACE_CLI_TOKEN` | Pre-obtained OAuth2 access token (highest priority) | | `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` | Path to OAuth credentials JSON (user or service account) | +| `GOOGLE_WORKSPACE_CLI_IMPERSONATED_USER` | Email to impersonate via domain-wide delegation (service accounts only) | | `GOOGLE_WORKSPACE_CLI_CLIENT_ID` | OAuth client ID (alternative to `client_secret.json`) | | `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET` | OAuth client secret (paired with `CLIENT_ID`) | | `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` | Override config directory (default: `~/.config/gws`) | diff --git a/src/auth.rs b/src/auth.rs index b602d840..ee03f17b 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -164,19 +164,21 @@ pub async fn get_token(scopes: &[&str]) -> anyhow::Result { } let creds_file = std::env::var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE").ok(); + let impersonated_user = std::env::var("GOOGLE_WORKSPACE_CLI_IMPERSONATED_USER").ok(); let config_dir = crate::auth_commands::config_dir(); let enc_path = credential_store::encrypted_credentials_path(); let default_path = config_dir.join("credentials.json"); let token_cache = config_dir.join("token_cache.json"); let creds = load_credentials_inner(creds_file.as_deref(), &enc_path, &default_path).await?; - get_token_inner(scopes, creds, &token_cache).await + get_token_inner(scopes, creds, &token_cache, impersonated_user.as_deref()).await } async fn get_token_inner( scopes: &[&str], creds: Credential, token_cache_path: &std::path::Path, + impersonated_user: Option<&str>, ) -> anyhow::Result { match creds { Credential::AuthorizedUser(secret) => { @@ -200,10 +202,17 @@ async fn get_token_inner( .map(|f| f.to_string_lossy().to_string()) .unwrap_or_else(|| "token_cache.json".to_string()); let sa_cache = token_cache_path.with_file_name(format!("sa_{tc_filename}")); - let builder = yup_oauth2::ServiceAccountAuthenticator::builder(key).with_storage( + let mut builder = yup_oauth2::ServiceAccountAuthenticator::builder(key).with_storage( Box::new(crate::token_storage::EncryptedTokenStorage::new(sa_cache)), ); + // Domain-wide delegation: set the impersonated user (sub claim) on the JWT + if let Some(user) = impersonated_user { + if !user.trim().is_empty() { + builder = builder.subject(user.to_string()); + } + } + let auth = builder .build() .await diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 6d0ffb7d..49fc12c9 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -963,6 +963,13 @@ async fn handle_status() -> Result<(), GwsError> { "token_cache_exists": has_token_cache, }); + // Show impersonated user if set (domain-wide delegation) + if let Ok(user) = std::env::var("GOOGLE_WORKSPACE_CLI_IMPERSONATED_USER") { + if !user.is_empty() { + output["impersonated_user"] = json!(user); + } + } + // Show client config (client_secret.json) status let config_path = crate::oauth_config::client_config_path(); let has_config = config_path.exists(); diff --git a/src/main.rs b/src/main.rs index 41dcc1e1..31185d73 100644 --- a/src/main.rs +++ b/src/main.rs @@ -487,6 +487,7 @@ fn print_usage() { println!( " GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND Keyring backend: keyring (default) or file" ); + println!(" GOOGLE_WORKSPACE_CLI_IMPERSONATED_USER Email to impersonate via domain-wide delegation (SA only)"); println!(" GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE Default Model Armor template"); println!( " GOOGLE_WORKSPACE_CLI_SANITIZE_MODE Sanitization mode: warn (default) or block" From 07137abac2c4966e46902976a9fcd1ecc175f6e4 Mon Sep 17 00:00:00 2001 From: pureugong Date: Mon, 23 Mar 2026 00:26:57 +0900 Subject: [PATCH 2/3] fix: use trim() for impersonated_user check in auth status to match auth logic --- src/auth_commands.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 49fc12c9..5942388e 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -965,7 +965,7 @@ async fn handle_status() -> Result<(), GwsError> { // Show impersonated user if set (domain-wide delegation) if let Ok(user) = std::env::var("GOOGLE_WORKSPACE_CLI_IMPERSONATED_USER") { - if !user.is_empty() { + if !user.trim().is_empty() { output["impersonated_user"] = json!(user); } } From f652d737722c6c2ee9068da70829912ac38efebc Mon Sep 17 00:00:00 2001 From: pureugong Date: Mon, 23 Mar 2026 00:38:37 +0900 Subject: [PATCH 3/3] refactor: centralize impersonated user env var into get_impersonated_user() helper --- src/auth.rs | 15 +++++++++++---- src/auth_commands.rs | 6 ++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index ee03f17b..d4e494a7 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -24,6 +24,15 @@ use anyhow::Context; use crate::credential_store; +const IMPERSONATED_USER_ENV: &str = "GOOGLE_WORKSPACE_CLI_IMPERSONATED_USER"; + +/// Returns the impersonated user email for domain-wide delegation, if set. +pub fn get_impersonated_user() -> Option { + std::env::var(IMPERSONATED_USER_ENV) + .ok() + .filter(|val| !val.trim().is_empty()) +} + /// Returns the project ID to be used for quota and billing (sets the `x-goog-user-project` header). /// /// Priority: @@ -164,7 +173,7 @@ pub async fn get_token(scopes: &[&str]) -> anyhow::Result { } let creds_file = std::env::var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE").ok(); - let impersonated_user = std::env::var("GOOGLE_WORKSPACE_CLI_IMPERSONATED_USER").ok(); + let impersonated_user = get_impersonated_user(); let config_dir = crate::auth_commands::config_dir(); let enc_path = credential_store::encrypted_credentials_path(); let default_path = config_dir.join("credentials.json"); @@ -208,9 +217,7 @@ async fn get_token_inner( // Domain-wide delegation: set the impersonated user (sub claim) on the JWT if let Some(user) = impersonated_user { - if !user.trim().is_empty() { - builder = builder.subject(user.to_string()); - } + builder = builder.subject(user.to_string()); } let auth = builder diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 5942388e..c8aa8c00 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -964,10 +964,8 @@ async fn handle_status() -> Result<(), GwsError> { }); // Show impersonated user if set (domain-wide delegation) - if let Ok(user) = std::env::var("GOOGLE_WORKSPACE_CLI_IMPERSONATED_USER") { - if !user.trim().is_empty() { - output["impersonated_user"] = json!(user); - } + if let Some(user) = crate::auth::get_impersonated_user() { + output["impersonated_user"] = json!(user); } // Show client config (client_secret.json) status