shahar1 commented on code in PR #75248:
URL: https://github.com/apache/airflow/pull/75248#discussion_r4503271743


##########
providers/databricks/src/airflow/providers/databricks/hooks/databricks_base.py:
##########
@@ -612,6 +624,107 @@ async def 
_a_get_aad_token_for_default_az_credential(self, resource: str) -> str
 
         return token.token
 
+    @staticmethod
+    def _is_jwt_token_valid(token: str) -> bool:
+        """
+        Check if a JWT token hasn't expired yet.
+
+        Only the ``exp`` claim of the payload is inspected; the signature is 
not verified
+        here because this check only decides whether the cached token can be 
reused --
+        the token itself is verified by Databricks.
+
+        :param token: JWT token string
+        :return: true if the token is well-formed and expires later than the 
refresh
+            lead time, false otherwise
+        """
+        try:
+            payload = token.split(".")[1]
+            payload += "=" * (-len(payload) % 4)
+            decoded = json.loads(base64.urlsafe_b64decode(payload))
+            return int(decoded["exp"]) > (int(time.time()) + 
TOKEN_REFRESH_LEAD_TIME)
+        except (IndexError, KeyError, TypeError, ValueError):
+            return False
+
+    def _get_google_id_token(self) -> str:
+        """
+        Get a Google-signed OpenID Connect ID token.
+
+        The token is issued for the audience configured in the 
``google_id_token_audience``
+        connection extra. If ``google_impersonated_service_account`` is set, 
the default
+        credentials (e.g. from ``GOOGLE_APPLICATION_CREDENTIALS``) impersonate 
that service
+        account through the IAM Credentials API; otherwise the token is 
fetched for the
+        default credentials themselves.
+
+        :return: Google ID token, or raise an exception
+        """
+        audience = 
self.databricks_conn.extra_dejson.get("google_id_token_audience")
+        if not audience:
+            raise AirflowException(
+                "`google_id_token_audience` must be present in Connection 
extra "
+                "when `use_google_id_token` is enabled"
+            )
+        impersonated_service_account = self.databricks_conn.extra_dejson.get(
+            "google_impersonated_service_account"
+        )
+        resource = f"{audience}-{impersonated_service_account}"
+
+        google_id_token = self.jwt_tokens.get(resource)
+        if google_id_token and self._is_jwt_token_valid(google_id_token):
+            return google_id_token
+
+        self.log.info("Existing Google ID token is expired, or going to expire 
soon. Refreshing...")
+        try:
+            import google.auth
+            from google.auth.transport.requests import AuthorizedSession, 
Request
+            from google.oauth2 import id_token
+
+            for attempt in self._get_retry_object():
+                with attempt:
+                    if impersonated_service_account:
+                        credentials, _ = 
google.auth.default(scopes=DEFAULT_GCP_SCOPES)
+                        authed_session = AuthorizedSession(credentials)
+                        resp = authed_session.post(

Review Comment:
   **minor**: this is a hand-rolled `generateIdToken` call, using its own URL 
constant and the `AuthorizedSession` POST. Together with the custom JWT decoder 
in `_is_jwt_token_valid`, it re-implements something google-auth already offers 
as public API:
   
   ```python
   from google.auth import impersonated_credentials
   
   source_credentials, _ = google.auth.default(scopes=DEFAULT_GCP_SCOPES)
   target = impersonated_credentials.Credentials(
       source_credentials=source_credentials,
       target_principal=impersonated_service_account,
       target_scopes=DEFAULT_GCP_SCOPES,
   )
   id_creds = impersonated_credentials.IDTokenCredentials(target, 
target_audience=audience, include_email=True)
   id_creds.refresh(Request())
   # id_creds.token, id_creds.expiry
   ```
   
   The Databricks SDK's own `google-id` credentials provider does exactly this 
(`databricks/sdk/credentials_provider.py`). Both the impersonated and the 
`fetch_id_token_credentials` paths return credentials objects with an `expiry` 
attribute. If you cache that object (or the token together with its expiry), 
you can drop `_is_jwt_token_valid` and the `GOOGLE_IMPERSONATED_ID_TOKEN_URL` 
constant. This is not a project rule, just less code to maintain; your call.



##########
providers/databricks/src/airflow/providers/databricks/hooks/databricks_base.py:
##########
@@ -612,6 +624,107 @@ async def 
_a_get_aad_token_for_default_az_credential(self, resource: str) -> str
 
         return token.token
 
+    @staticmethod
+    def _is_jwt_token_valid(token: str) -> bool:
+        """
+        Check if a JWT token hasn't expired yet.
+
+        Only the ``exp`` claim of the payload is inspected; the signature is 
not verified
+        here because this check only decides whether the cached token can be 
reused --
+        the token itself is verified by Databricks.
+
+        :param token: JWT token string
+        :return: true if the token is well-formed and expires later than the 
refresh
+            lead time, false otherwise
+        """
+        try:
+            payload = token.split(".")[1]
+            payload += "=" * (-len(payload) % 4)
+            decoded = json.loads(base64.urlsafe_b64decode(payload))
+            return int(decoded["exp"]) > (int(time.time()) + 
TOKEN_REFRESH_LEAD_TIME)
+        except (IndexError, KeyError, TypeError, ValueError):
+            return False
+
+    def _get_google_id_token(self) -> str:
+        """
+        Get a Google-signed OpenID Connect ID token.
+
+        The token is issued for the audience configured in the 
``google_id_token_audience``
+        connection extra. If ``google_impersonated_service_account`` is set, 
the default
+        credentials (e.g. from ``GOOGLE_APPLICATION_CREDENTIALS``) impersonate 
that service
+        account through the IAM Credentials API; otherwise the token is 
fetched for the
+        default credentials themselves.
+
+        :return: Google ID token, or raise an exception
+        """
+        audience = 
self.databricks_conn.extra_dejson.get("google_id_token_audience")
+        if not audience:
+            raise AirflowException(

Review Comment:
   **major**: this is one of four new `raise AirflowException` in this PR (also 
lines 698, 707 and 710). `generated/known_airflow_exceptions.txt` allows 44 in 
`databricks_base.py`, and the file now has 48, so the 
`check-no-new-airflow-exceptions` static check will fail.
   
   > **Flag any new `raise AirflowException` usage.** The community is reducing 
direct `AirflowException` usage, not increasing it; new ones are not allowed 
(enforced by the `check-no-new-airflow-exceptions` prek hook) — prefer Python's 
standard exceptions (`ValueError`, `TypeError`, `OSError`), or a dedicated 
class in the appropriate `exceptions.py`.
   
   ([code-review 
instructions](https://github.com/apache/airflow/blob/main/.github/instructions/code-review.instructions.md#code-quality-rules))
   
   Suggestion: raise `ValueError` for the missing `google_id_token_audience`. 
For the fetch failures (empty token, exhausted retries, HTTP error), add a 
dedicated class to `providers/databricks/exceptions.py`, next to 
`DatabricksApiError`. The existing Azure branches still use `AirflowException`, 
but they are already on the allowlist. Please don't copy that pattern into new 
code.



##########
providers/databricks/tests/unit/databricks/hooks/test_databricks_google_id_token.py:
##########
@@ -0,0 +1,215 @@
+# 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.
+from __future__ import annotations
+
+import base64
+import json
+import time
+from unittest import mock
+
+import pytest
+
+from airflow.models import Connection
+from airflow.providers.common.compat.sdk import AirflowException, 
AirflowOptionalProviderFeatureException
+from airflow.providers.databricks.hooks.databricks_base import (
+    DEFAULT_GCP_SCOPES,
+    GOOGLE_ID_TOKEN_SETTING_KEY,
+    BaseDatabricksHook,
+)
+
+AUDIENCE = "test-audience"
+IMPERSONATED_SERVICE_ACCOUNT = 
"[email protected]"
+IMPERSONATE_URL = (
+    "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/";
+    f"{IMPERSONATED_SERVICE_ACCOUNT}:generateIdToken"
+)
+
+
+def _make_jwt(exp: int) -> str:
+    """Build an unsigned JWT string carrying only an ``exp`` claim (test 
fixture)."""
+
+    def _b64(payload: dict) -> str:
+        return 
base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
+
+    return f"{_b64({'alg': 'none', 'typ': 'JWT'})}.{_b64({'exp': 
exp})}.signature"
+
+
+def _valid_jwt() -> str:
+    return _make_jwt(int(time.time()) + 3600)
+
+
+def _expired_jwt() -> str:
+    return _make_jwt(int(time.time()) - 3600)
+
+
+def _hook_with_extra(extra: dict) -> BaseDatabricksHook:
+    hook = BaseDatabricksHook()
+    hook.databricks_conn = Connection(host="example.databricks.com", 
extra=extra)
+    return hook
+
+
+class TestGoogleIdTokenDispatch:
+    @mock.patch(
+        
"airflow.providers.databricks.hooks.databricks_base.BaseDatabricksHook.databricks_conn",
+        new_callable=mock.PropertyMock,
+    )
+    @mock.patch.object(BaseDatabricksHook, "_get_google_id_token")
+    def test_get_token_google_id_token_success(self, mock_get_google_id_token, 
mock_conn):
+        mock_conn.return_value = 
Connection(extra={GOOGLE_ID_TOKEN_SETTING_KEY: True})
+        mock_get_google_id_token.return_value = "google_id_token"
+        hook = BaseDatabricksHook()
+        with mock.patch.object(hook.log, "debug") as mock_log_debug:
+            token = hook._get_token()
+            assert token == "google_id_token"
+            mock_get_google_id_token.assert_called_once_with()
+            mock_log_debug.assert_called_once_with("Using Google ID Token.")
+
+    @pytest.mark.asyncio
+    @mock.patch(
+        
"airflow.providers.databricks.hooks.databricks_base.BaseDatabricksHook._a_get_google_id_token",
+        new_callable=mock.AsyncMock,
+    )
+    @mock.patch(
+        
"airflow.providers.databricks.hooks.databricks_base.BaseDatabricksHook.databricks_conn",
+        new_callable=mock.PropertyMock,
+    )
+    async def test_a_get_token_google_id_token_success(self, mock_conn, 
mock_a_get_google_id_token):
+        mock_conn.return_value = 
Connection(extra={GOOGLE_ID_TOKEN_SETTING_KEY: True})
+        mock_a_get_google_id_token.return_value = "google_id_token"
+        hook = BaseDatabricksHook()
+        with mock.patch.object(hook.log, "debug") as mock_log_debug:
+            token = await hook._a_get_token()
+            assert token == "google_id_token"
+            mock_a_get_google_id_token.assert_awaited_once_with()
+            mock_log_debug.assert_called_once_with("Using Google ID Token.")
+
+
+class TestGetGoogleIdToken:

Review Comment:
   **minor**: three error branches of `_get_google_id_token` have no test: the 
empty or missing `token` in the response (`"Google ID token couldn't be 
obtained"`), exhausted retries (`RetryError`, for example a 503 on every 
attempt), and a non-retryable `HTTPError` (for example 403 from IAM 
Credentials). Reverting any of them leaves the suite green.
   
   > **Flag any changed or added behaviour without a corresponding test, and 
flag tests that a reviewer cannot fail by reverting the PR's change.** The 
target is exactly 100% coverage of what the PR changes — no more, no less.
   
   ([code-review 
instructions](https://github.com/apache/airflow/blob/main/.github/instructions/code-review.instructions.md#testing-requirements))



##########
providers/databricks/docs/connections/databricks.rst:
##########
@@ -137,6 +142,28 @@ Extra (optional)
       a user inside workspace)
     * ``azure_managed_identity_client_id``: optional client ID of the 
user-assigned managed identity. This parameter is only required if you're using 
a user-assigned managed identity. If not specified, the hook will attempt to 
authenticate using a system-assigned managed identity.
 
+    Following parameters are necessary if using authentication with a 
Google-signed OpenID Connect ID token
+    (requires the ``google`` extra, ``pip install 
apache-airflow-providers-databricks[google]``):
+
+    * ``use_google_id_token``: required boolean flag to specify that a Google 
ID token should be used
+    * ``google_id_token_audience``: required audience (``aud`` claim) of the 
ID token, as configured for

Review Comment:
   **nit**: for workspace-level API calls, the audience Databricks expects is 
the workspace URL. The Databricks SDK's `google-id` provider passes 
`target_audience=cfg.host`. You could default `google_id_token_audience` to 
`https://<host>` and make it optional, which also makes the placeholder in the 
JSON example below more concrete. Feel free to keep it required if you prefer 
an explicit value.



##########
providers/databricks/tests/unit/databricks/hooks/test_databricks_google_id_token.py:
##########
@@ -0,0 +1,215 @@
+# 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.
+from __future__ import annotations
+
+import base64
+import json
+import time
+from unittest import mock
+
+import pytest
+
+from airflow.models import Connection
+from airflow.providers.common.compat.sdk import AirflowException, 
AirflowOptionalProviderFeatureException
+from airflow.providers.databricks.hooks.databricks_base import (
+    DEFAULT_GCP_SCOPES,
+    GOOGLE_ID_TOKEN_SETTING_KEY,
+    BaseDatabricksHook,
+)
+
+AUDIENCE = "test-audience"
+IMPERSONATED_SERVICE_ACCOUNT = 
"[email protected]"
+IMPERSONATE_URL = (
+    "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/";
+    f"{IMPERSONATED_SERVICE_ACCOUNT}:generateIdToken"
+)
+
+
+def _make_jwt(exp: int) -> str:
+    """Build an unsigned JWT string carrying only an ``exp`` claim (test 
fixture)."""
+
+    def _b64(payload: dict) -> str:
+        return 
base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
+
+    return f"{_b64({'alg': 'none', 'typ': 'JWT'})}.{_b64({'exp': 
exp})}.signature"
+
+
+def _valid_jwt() -> str:
+    return _make_jwt(int(time.time()) + 3600)
+
+
+def _expired_jwt() -> str:
+    return _make_jwt(int(time.time()) - 3600)
+
+
+def _hook_with_extra(extra: dict) -> BaseDatabricksHook:
+    hook = BaseDatabricksHook()
+    hook.databricks_conn = Connection(host="example.databricks.com", 
extra=extra)
+    return hook
+
+
+class TestGoogleIdTokenDispatch:
+    @mock.patch(
+        
"airflow.providers.databricks.hooks.databricks_base.BaseDatabricksHook.databricks_conn",
+        new_callable=mock.PropertyMock,
+    )
+    @mock.patch.object(BaseDatabricksHook, "_get_google_id_token")
+    def test_get_token_google_id_token_success(self, mock_get_google_id_token, 
mock_conn):
+        mock_conn.return_value = 
Connection(extra={GOOGLE_ID_TOKEN_SETTING_KEY: True})
+        mock_get_google_id_token.return_value = "google_id_token"
+        hook = BaseDatabricksHook()
+        with mock.patch.object(hook.log, "debug") as mock_log_debug:
+            token = hook._get_token()
+            assert token == "google_id_token"
+            mock_get_google_id_token.assert_called_once_with()
+            mock_log_debug.assert_called_once_with("Using Google ID Token.")
+
+    @pytest.mark.asyncio
+    @mock.patch(
+        
"airflow.providers.databricks.hooks.databricks_base.BaseDatabricksHook._a_get_google_id_token",
+        new_callable=mock.AsyncMock,
+    )
+    @mock.patch(
+        
"airflow.providers.databricks.hooks.databricks_base.BaseDatabricksHook.databricks_conn",
+        new_callable=mock.PropertyMock,
+    )
+    async def test_a_get_token_google_id_token_success(self, mock_conn, 
mock_a_get_google_id_token):
+        mock_conn.return_value = 
Connection(extra={GOOGLE_ID_TOKEN_SETTING_KEY: True})
+        mock_a_get_google_id_token.return_value = "google_id_token"
+        hook = BaseDatabricksHook()
+        with mock.patch.object(hook.log, "debug") as mock_log_debug:
+            token = await hook._a_get_token()
+            assert token == "google_id_token"
+            mock_a_get_google_id_token.assert_awaited_once_with()
+            mock_log_debug.assert_called_once_with("Using Google ID Token.")
+
+
+class TestGetGoogleIdToken:
+    def test_missing_audience_raises(self):
+        hook = _hook_with_extra({GOOGLE_ID_TOKEN_SETTING_KEY: True})
+        with pytest.raises(AirflowException, match="google_id_token_audience"):
+            hook._get_google_id_token()
+
+    @mock.patch("google.oauth2.id_token.fetch_id_token")
+    @mock.patch("google.auth.transport.requests.AuthorizedSession")
+    @mock.patch("google.auth.default")
+    def test_impersonated_service_account(self, mock_default, 
mock_session_class, mock_fetch_id_token):
+        credentials = mock.Mock()

Review Comment:
   **nit**: this mock has no spec. The same applies to the 
`mock.patch.object(BaseDatabricksHook, "_get_google_id_token")` patches, which 
have no `autospec=True`.
   
   > **Flag any `mock.Mock()` or `mock.MagicMock()` without `spec` or 
`autospec`.** Unspec'd mocks silently accept any attribute access, hiding real 
bugs.
   
   ([code-review 
instructions](https://github.com/apache/airflow/blob/main/.github/instructions/code-review.instructions.md#testing-requirements))
   
   ```suggestion
           credentials = mock.Mock(spec=google.auth.credentials.Credentials)
   ```
   
   (This needs `import google.auth.credentials` at the top of the test module.)



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to