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

Lee-W pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 1e14a3d6bcd Add quota project id support to Google cloud base hook 
(#56324)
1e14a3d6bcd is described below

commit 1e14a3d6bcd10fa1c735615f4d2c961051ec7c03
Author: Ankurdeewan <[email protected]>
AuthorDate: Tue Apr 21 12:03:03 2026 +0530

    Add quota project id support to Google cloud base hook (#56324)
    
    Co-authored-by: Wei Lee <[email protected]>
    Co-authored-by: Shahar Epstein <[email protected]>
---
 providers/google/docs/connections/gcp.rst          | 84 +++++++++++++++++++++-
 .../providers/google/common/hooks/base_google.py   | 81 ++++++++++++++++++++-
 .../unit/google/common/hooks/test_base_google.py   | 53 ++++++++++++++
 3 files changed, 216 insertions(+), 2 deletions(-)

diff --git a/providers/google/docs/connections/gcp.rst 
b/providers/google/docs/connections/gcp.rst
index 776f53a50a0..aedf9af1e23 100644
--- a/providers/google/docs/connections/gcp.rst
+++ b/providers/google/docs/connections/gcp.rst
@@ -16,7 +16,6 @@
     under the License.
 
 
-
 .. _howto/connection:google_cloud_platform:
 
 Google Cloud Connection
@@ -121,6 +120,18 @@ Scopes (comma separated)
     <https://developers.google.com/identity/protocols/googlescopes>`_ to
     authenticate with.
 
+
+Quota Project ID (optional)
+    The Google Cloud project ID to use for API quota and billing purposes. 
This is useful
+    when using a shared service account but want to attribute quota/billing to 
a different
+    project. If not specified, no separate quota project is configured on the 
credentials
+    and Google Cloud's default behavior applies. Must be a valid GCP project 
ID (lowercase
+    letters, digits, hyphens, 6-30 characters, starting with a letter).
+
+    .. note:: If using anonymous credentials, quota project logic is ignored.
+
+    .. warning:: Ensure the service account has permission to use the 
specified quota project; an invalid or unauthorized project ID will result in 
an API error.
+
 Number of Retries
     Integer, number of times to retry with randomized
     exponential backoff. If all retries fail, the 
:class:`googleapiclient.errors.HttpError`
@@ -309,3 +320,74 @@ Note that as domain-wide delegation is currently supported 
by most of the Google
 * All of Google Cloud operators and hooks.
 * Firebase hooks.
 * All transfer operators that involve Google cloud in different providers, for 
example: 
:class:`airflow.providers.amazon.aws.transfers.gcs_to_s3.GCSToS3Operator`.
+
+
+Quota Project Support
+---------------------
+
+Airflow's Google Cloud providers support specifying a "quota project" (a 
billing project) for
+API calls. That lets API usage be billed to a different Google Cloud project 
than the one that
+owns the service account. This is useful for organizations that share service 
accounts but
+centralize billing in specific projects.
+
+Usage
+~~~~~
+
+There are two ways to set a quota project in Airflow:
+
+- Via connection extras (recommended for environment-wide defaults).
+- Directly on supported hook instances (recommended when a single task must 
bill to a different project).
+
+Connection extras
+^^^^^^^^^^^^^^^^^
+
+Add the quota project ID to the Google Cloud connection extras. For example:
+
+.. code-block:: json
+
+  {
+    "quota_project_id": "your-billing-project-id"
+  }
+
+You can set this via the Airflow UI, the Connections REST API, or an 
environment variable, for
+example:
+
+.. code-block:: bash
+
+  export AIRFLOW_CONN_GOOGLE_CLOUD_DEFAULT='{
+    "conn_type": "google_cloud_platform",
+    "extra": {
+      "quota_project_id": "your-billing-project-id"
+    }
+  }'
+
+Hook parameter
+^^^^^^^^^^^^^^
+
+You can also pass the quota project directly when creating a hook. This takes
+precedence over the connection extras:
+
+.. code-block:: python
+
+  from airflow.providers.google.cloud.hooks.bigquery import BigQueryHook
+
+  hook = BigQueryHook(quota_project_id="your-billing-project-id")
+
+Priority
+^^^^^^^^
+
+If a quota project is provided both in the connection extras and as a hook
+parameter, the hook parameter wins.
+
+Compatibility
+^^^^^^^^^^^^^
+
+This setting works with Google Cloud services that support the quota project 
mechanism (the
+``x-goog-user-project`` header), for example BigQuery, Cloud Storage, Dataflow,
+and other Google Cloud APIs that accept quota project headers.
+
+Impact
+^^^^^^
+
+Using a quota project affects where API usage is billed, which quotas are 
applied, and how
+usage is reported for monitoring and auditing.
diff --git 
a/providers/google/src/airflow/providers/google/common/hooks/base_google.py 
b/providers/google/src/airflow/providers/google/common/hooks/base_google.py
index 0a7a0805a4f..579300d18bb 100644
--- a/providers/google/src/airflow/providers/google/common/hooks/base_google.py
+++ b/providers/google/src/airflow/providers/google/common/hooks/base_google.py
@@ -25,6 +25,7 @@ import functools
 import json
 import logging
 import os
+import re
 import tempfile
 from collections.abc import Callable, Generator, Sequence
 from contextlib import ExitStack, contextmanager
@@ -123,6 +124,26 @@ def is_refresh_credentials_exception(exception: Exception) 
-> bool:
     return False
 
 
+_GCP_PROJECT_ID_PATTERN = re.compile(r"^[a-z][a-z0-9\-]{4,28}[a-z0-9]$")
+
+
+def is_valid_gcp_project_id(project_id: str) -> bool:
+    """
+    Validate a Google Cloud Project ID format.
+
+    A valid project ID must:
+
+    - Be 6 to 30 characters long
+    - Start with a lowercase letter
+    - Contain only lowercase letters, digits, and hyphens
+    - Not end with a hyphen
+
+    :param project_id: The project ID string to validate.
+    :return: True if the project ID is valid, False otherwise.
+    """
+    return bool(_GCP_PROJECT_ID_PATTERN.match(project_id))
+
+
 class retry_if_temporary_quota(tenacity.retry_if_exception):
     """Retries if there was an exception for exceeding the temporary quote 
limit."""
 
@@ -206,6 +227,10 @@ class GoogleBaseHook(BaseHook):
         If set as a sequence, the identities from the list must grant
         Service Account Token Creator IAM role to the directly preceding 
identity, with first
         account from the list granting this role to the originating account.
+    :param quota_project_id: Optional Google Cloud project ID to use for 
billing and
+        quota purposes. If set, API usage will be charged to this project 
instead of
+        the project associated with the credentials. If not set, the 
connection's
+        ``quota_project_id`` field is used as a fallback.
     """
 
     conn_name_attr = "gcp_conn_id"
@@ -261,6 +286,7 @@ class GoogleBaseHook(BaseHook):
             "is_anonymous": BooleanField(
                 lazy_gettext("Anonymous credentials (ignores all other 
settings)"), default=False
             ),
+            "quota_project_id": StringField(lazy_gettext("Quota Project ID"), 
widget=BS3TextFieldWidget()),
         }
 
     @classmethod
@@ -275,11 +301,27 @@ class GoogleBaseHook(BaseHook):
         self,
         gcp_conn_id: str = "google_cloud_default",
         impersonation_chain: str | Sequence[str] | None = None,
+        *,
+        quota_project_id: str | None = None,
         **kwargs,
     ) -> None:
+        """
+        Initialize the Google Cloud Base Hook.
+
+        :param gcp_conn_id: The connection ID to use when fetching connection 
info.
+        :param impersonation_chain: Optional service account to impersonate 
using short-term
+            credentials.
+        :param quota_project_id: Optional Project ID to use for quota/billing 
purposes.
+            If None, no separate quota project is configured and the default 
behavior of the
+            credentials is used.
+        :param kwargs: Additional arguments to pass to parent constructor.
+        """
         super().__init__(**kwargs)
         self.gcp_conn_id = gcp_conn_id
         self.impersonation_chain = impersonation_chain
+        if quota_project_id is not None:
+            self._validate_quota_project(quota_project_id)
+        self.quota_project_id = quota_project_id
         self.extras: dict = self.get_connection(self.gcp_conn_id).extra_dejson
         self._cached_credentials: Credentials | None = None
         self._cached_project_id: str | None = None
@@ -342,6 +384,19 @@ class GoogleBaseHook(BaseHook):
             idp_extra_params_dict=idp_extra_params_dict,
         )
 
+        # Apply quota project before caching credentials
+        quota_project = self.quota_project_id or 
self._get_field("quota_project_id")
+        if quota_project and not is_anonymous:
+            self._validate_quota_project(quota_project)
+            if not hasattr(credentials, "with_quota_project"):
+                raise ValueError(
+                    f"Credentials of type {type(credentials).__name__} do not 
support "
+                    "quota project configuration. Please use a different 
authentication method "
+                    "or remove the quota_project_id setting."
+                )
+            credentials = credentials.with_quota_project(quota_project)
+
+        # Override project_id if set in extras
         overridden_project_id = self._get_field("project")
         if overridden_project_id:
             project_id = overridden_project_id
@@ -351,8 +406,32 @@ class GoogleBaseHook(BaseHook):
 
         return credentials, project_id
 
+    def _validate_quota_project(self, quota_project: str) -> None:
+        """
+        Validate the quota Project ID format.
+
+        :param quota_project: The quota Project ID to validate
+        :raises TypeError: If the quota Project ID is not a string
+        :raises ValueError: If the quota Project ID is empty or does not match 
the expected format
+        """
+        if not isinstance(quota_project, str):
+            raise TypeError(f"quota_project_id must be a string, got 
{type(quota_project)}")
+        if not quota_project.strip():
+            raise ValueError("quota_project_id cannot be empty")
+        # Check for valid GCP Project ID format
+        if not is_valid_gcp_project_id(quota_project):
+            raise ValueError(
+                f"Invalid quota_project_id '{quota_project}'. "
+                "Project IDs must be 6-30 characters long, start with a 
lowercase letter, "
+                "and can contain only lowercase letters, digits, and hyphens."
+            )
+
     def get_credentials(self) -> Credentials:
-        """Return the Credentials object for Google API."""
+        """
+        Return the Credentials object for Google API.
+
+        :return: Google Cloud credentials object
+        """
         credentials, _ = self.get_credentials_and_project_id()
         return credentials
 
diff --git 
a/providers/google/tests/unit/google/common/hooks/test_base_google.py 
b/providers/google/tests/unit/google/common/hooks/test_base_google.py
index 46adde9b8f9..4a662143333 100644
--- a/providers/google/tests/unit/google/common/hooks/test_base_google.py
+++ b/providers/google/tests/unit/google/common/hooks/test_base_google.py
@@ -541,6 +541,59 @@ class TestGoogleBaseHook:
         )
         assert result == ("CREDENTIALS", "SECOND_PROJECT_ID")
 
+    def test_quota_project_id_init(self):
+        """Test that quota project ID is properly initialized."""
+        hook = GoogleBaseHook(gcp_conn_id="google_cloud_default", 
quota_project_id="test-quota-project")
+        assert hook.quota_project_id == "test-quota-project"
+
+    @mock.patch(MODULE_NAME + ".get_credentials_and_project_id")
+    def test_quota_project_id_from_connection(self, 
mock_get_creds_and_proj_id):
+        """Test that quota project ID from connection is applied to 
credentials."""
+        mock_creds = mock.MagicMock()
+        mock_creds.with_quota_project.return_value = mock_creds
+        mock_get_creds_and_proj_id.return_value = (mock_creds, "test-project")
+
+        # Mock connection with quota_project_id in extras
+        uri = "google-cloud-platform://?quota_project_id=test-quota-project"
+        with mock.patch.dict("os.environ", 
{"AIRFLOW_CONN_GOOGLE_CLOUD_DEFAULT": uri}):
+            hook = GoogleBaseHook(gcp_conn_id="google_cloud_default")
+            creds, _ = hook.get_credentials_and_project_id()
+            
mock_creds.with_quota_project.assert_called_once_with("test-quota-project")
+
+    @mock.patch(MODULE_NAME + ".get_credentials_and_project_id")
+    def test_quota_project_id_param_overrides_connection(self, 
mock_get_creds_and_proj_id):
+        """Test that quota project ID from param overrides connection value."""
+        mock_creds = mock.MagicMock()
+        mock_creds.with_quota_project.return_value = mock_creds
+        mock_get_creds_and_proj_id.return_value = (mock_creds, "test-project")
+
+        # Connection with quota_project_id in extras
+        conn_quota = "connection-quota-project"
+        uri = f"google-cloud-platform://?quota_project_id={conn_quota}"
+
+        with mock.patch.dict("os.environ", 
{"AIRFLOW_CONN_GOOGLE_CLOUD_DEFAULT": uri}):
+            hook = GoogleBaseHook(gcp_conn_id="google_cloud_default", 
quota_project_id="test-quota-project")
+            creds, _ = hook.get_credentials_and_project_id()
+
+            # Should use param quota project, not connection quota project
+            
mock_creds.with_quota_project.assert_called_once_with("test-quota-project")
+
+    @pytest.mark.parametrize(
+        "invalid_id",
+        [
+            "UPPERCASE",  # Must be lowercase
+            "special@chars",  # Invalid characters
+            "ab-cd",  # Too short
+            "a" + "b" * 30,  # Too long
+            "1starts-with-number",  # Must start with letter
+            "",  # Empty string
+        ],
+    )
+    def test_quota_project_invalid_format(self, invalid_id):
+        """Test validation of quota project ID format."""
+        with pytest.raises((TypeError, ValueError)):
+            GoogleBaseHook(quota_project_id=invalid_id)
+
     def 
test_get_credentials_and_project_id_with_mutually_exclusive_configuration(self):
         self.instance.extras = {
             "project": "PROJECT_ID",

Reply via email to