diff --git a/.changeset/no-quota-project-for-oauth.md b/.changeset/no-quota-project-for-oauth.md new file mode 100644 index 00000000..b2a74e7f --- /dev/null +++ b/.changeset/no-quota-project-for-oauth.md @@ -0,0 +1,14 @@ +--- +"@googleworkspace/cli": patch +--- + +Stop sending `x-goog-user-project` derived from the OAuth client's `project_id` + +The `project_id` in `client_secret.json` was used as the quota project on every request. +The API only honors that header when the authenticated end user holds +`serviceusage.services.use` on the project, so users who are not IAM members of it +received a 403 on every call. Quota for end-user OAuth credentials is already attributed +via the OAuth client ID. + +The header is still sent for ADC credentials with a `quota_project_id`, and can be set +explicitly via `GOOGLE_WORKSPACE_PROJECT_ID`. diff --git a/crates/google-workspace-cli/src/auth.rs b/crates/google-workspace-cli/src/auth.rs index 9d8847e4..0a6a39f3 100644 --- a/crates/google-workspace-cli/src/auth.rs +++ b/crates/google-workspace-cli/src/auth.rs @@ -84,8 +84,10 @@ async fn refresh_token_with_reqwest( /// /// Priority: /// 1. `GOOGLE_WORKSPACE_PROJECT_ID` environment variable. -/// 2. `project_id` from the OAuth client configuration (`client_secret.json`). -/// 3. `quota_project_id` from Application Default Credentials (ADC). +/// 2. `quota_project_id` from Application Default Credentials (ADC). +/// +/// The `project_id` from the OAuth client configuration (`client_secret.json`) is not +/// used: quota for end-user OAuth credentials is attributed via the OAuth client ID. pub fn get_quota_project() -> Option { // 1. Explicit environment variable (highest priority) if let Ok(project_id) = std::env::var("GOOGLE_WORKSPACE_PROJECT_ID") { @@ -94,14 +96,7 @@ pub fn get_quota_project() -> Option { } } - // 2. Project ID from the OAuth client configuration (set via `gws auth setup`) - if let Ok(config) = crate::oauth_config::load_client_config() { - if !config.project_id.is_empty() { - return Some(config.project_id); - } - } - - // 3. Fallback to Application Default Credentials (ADC) + // 2. Fallback to Application Default Credentials (ADC) let path = std::env::var("GOOGLE_APPLICATION_CREDENTIALS") .ok() .map(PathBuf::from) @@ -936,20 +931,41 @@ mod tests { #[test] #[serial_test::serial] - fn test_get_quota_project_priority_config() { + fn test_get_quota_project_ignores_client_config() { let tmp = tempfile::tempdir().unwrap(); let _config_guard = EnvVarGuard::set( "GOOGLE_WORKSPACE_CLI_CONFIG_DIR", tmp.path().to_str().unwrap(), ); let _env_guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_PROJECT_ID"); - let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS"); - let _home_guard = EnvVarGuard::set("HOME", "/missing/home"); + let _adc_guard = EnvVarGuard::set( + "GOOGLE_APPLICATION_CREDENTIALS", + tmp.path().join("missing-adc.json").to_str().unwrap(), + ); // Save a client config with a project ID crate::oauth_config::save_client_config("id", "secret", "config-project").unwrap(); - assert_eq!(get_quota_project(), Some("config-project".to_string())); + assert_eq!(get_quota_project(), None); + } + + #[test] + #[serial_test::serial] + fn test_get_quota_project_env_var_overrides_client_config() { + let tmp = tempfile::tempdir().unwrap(); + let _config_guard = EnvVarGuard::set( + "GOOGLE_WORKSPACE_CLI_CONFIG_DIR", + tmp.path().to_str().unwrap(), + ); + let _env_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_PROJECT_ID", "explicit-project"); + let _adc_guard = EnvVarGuard::set( + "GOOGLE_APPLICATION_CREDENTIALS", + tmp.path().join("missing-adc.json").to_str().unwrap(), + ); + + crate::oauth_config::save_client_config("id", "secret", "config-project").unwrap(); + + assert_eq!(get_quota_project(), Some("explicit-project".to_string())); } #[test]