This is an automated email from the ASF dual-hosted git repository.
eladkal 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 47e5d03826f Databricks OIDC token federation for AWS IAM (#69509)
47e5d03826f is described below
commit 47e5d03826fc4262e93f90ad0613579c1492d330
Author: Allen <[email protected]>
AuthorDate: Thu Jul 16 10:17:17 2026 -0700
Databricks OIDC token federation for AWS IAM (#69509)
* Add AWS IAM OIDC token federation to Databricks connection
* Update Databricks OIDC to match described precendence
* Add regression precedence test for Databricks OIDC
---
dev/breeze/tests/test_selective_checks.py | 8 +-
.../databricks/docs/connections/databricks.rst | 13 ++
providers/databricks/docs/index.rst | 4 +-
providers/databricks/pyproject.toml | 4 +
.../providers/databricks/hooks/databricks_base.py | 80 ++++++++-
.../unit/databricks/hooks/test_databricks_base.py | 198 ++++++++++++++++++++-
6 files changed, 297 insertions(+), 10 deletions(-)
diff --git a/dev/breeze/tests/test_selective_checks.py
b/dev/breeze/tests/test_selective_checks.py
index 25da64bd517..60371125ade 100644
--- a/dev/breeze/tests/test_selective_checks.py
+++ b/dev/breeze/tests/test_selective_checks.py
@@ -1083,7 +1083,7 @@ def assert_outputs_are_printed(expected_outputs:
dict[str, str], stderr: str):
("providers/amazon/src/airflow/providers/amazon/provider.yaml",),
{
"selected-providers-list-as-string": "amazon apache.hive
cncf.kubernetes "
- "common.compat common.messaging common.sql exasol ftp google
http imap microsoft.azure "
+ "common.compat common.messaging common.sql databricks exasol
ftp google http imap microsoft.azure "
"mongo mysql openlineage postgres salesforce ssh teradata",
"all-python-versions":
f"['{DEFAULT_PYTHON_MAJOR_MINOR_VERSION}']",
"all-python-versions-list-as-string":
DEFAULT_PYTHON_MAJOR_MINOR_VERSION,
@@ -1109,7 +1109,7 @@ def assert_outputs_are_printed(expected_outputs:
dict[str, str], stderr: str):
{
"description": "amazon...google",
"test_types": "Providers[amazon]
Providers[apache.hive,cncf.kubernetes,"
-
"common.compat,common.messaging,common.sql,exasol,ftp,http,imap,"
+
"common.compat,common.messaging,common.sql,databricks,exasol,ftp,http,imap,"
"microsoft.azure,mongo,mysql,openlineage,postgres,salesforce,ssh,teradata] "
"Providers[google]",
}
@@ -1155,7 +1155,7 @@ def assert_outputs_are_printed(expected_outputs:
dict[str, str], stderr: str):
("providers/amazon/src/airflow/providers/amazon/file.py",),
{
"selected-providers-list-as-string": "amazon apache.hive
cncf.kubernetes "
- "common.compat common.messaging common.sql exasol ftp google
http imap microsoft.azure "
+ "common.compat common.messaging common.sql databricks exasol
ftp google http imap microsoft.azure "
"mongo mysql openlineage postgres salesforce ssh teradata",
"all-python-versions":
f"['{DEFAULT_PYTHON_MAJOR_MINOR_VERSION}']",
"all-python-versions-list-as-string":
DEFAULT_PYTHON_MAJOR_MINOR_VERSION,
@@ -1178,7 +1178,7 @@ def assert_outputs_are_printed(expected_outputs:
dict[str, str], stderr: str):
{
"description": "amazon...google",
"test_types": "Providers[amazon]
Providers[apache.hive,cncf.kubernetes,"
-
"common.compat,common.messaging,common.sql,exasol,ftp,http,imap,"
+
"common.compat,common.messaging,common.sql,databricks,exasol,ftp,http,imap,"
"microsoft.azure,mongo,mysql,openlineage,postgres,salesforce,ssh,teradata] "
"Providers[google]",
}
diff --git a/providers/databricks/docs/connections/databricks.rst
b/providers/databricks/docs/connections/databricks.rst
index fb7eb80157c..a7e6b801e80 100644
--- a/providers/databricks/docs/connections/databricks.rst
+++ b/providers/databricks/docs/connections/databricks.rst
@@ -52,6 +52,8 @@ There are several ways to connect to Databricks using Airflow.
i.e. a caller-provided callable returns a short-lived OIDC JWT that is
exchanged for a Databricks OAuth token. Unlike the Kubernetes method,
the subject token is obtained in-process (never read from disk) and can
come from any federation-trusted OIDC issuer, so it is not tied to
Kubernetes and supports both account-wide and service-principal federation
policies. Like the Kubernetes method, no long-lived secret is stored in the
connection.
+8. Using AWS IAM `OIDC token federation
<https://docs.databricks.com/aws/en/dev-tools/auth/provider-aws-iam>`__
(applicable when Airflow runs with AWS credentials)
+ i.e. mint an AWS-signed OIDC JWT via AWS STS ``GetWebIdentityToken`` and
exchange it for a Databricks OAuth token. Stores no secrets; requires the
provider's ``amazon`` extra.
Default Connection IDs
----------------------
@@ -172,6 +174,17 @@ Extra (optional)
resp.raise_for_status()
return resp.json()["token"]
+ The following parameters enable *AWS IAM OIDC token federation* — mint an
AWS-signed OIDC JWT via AWS STS
+ ``GetWebIdentityToken`` and exchange it for a Databricks token. Requires
the ``amazon`` extra
+ (``pip install apache-airflow-providers-databricks[amazon]``). See the
Databricks `AWS IAM workload identity
+ federation guide
<https://docs.databricks.com/aws/en/dev-tools/auth/provider-aws-iam>`_.
+
+ * ``federated_aws``: set ``login`` to ``"federated_aws"`` or add it as a
boolean flag (``{"federated_aws": true}``).
+ * ``aws_conn_id``: (optional) AWS connection used for the STS call
(default: ``aws_default``).
+ * ``aws_jwt_audience``: (optional) audience of the minted JWT; must match
the federation policy (default: ``databricks``).
+ * ``aws_web_identity_token_duration``: (optional) requested JWT lifetime
in seconds (default: ``300``, AWS max ``3600``).
+ * ``client_id``: (optional) service principal client UUID for a service
principal policy; omit for an account-wide policy.
+
The following parameters are necessary if using authentication with
Kubernetes OIDC token federation:
* ``federated_k8s``: set ``login`` to ``"federated_k8s"`` or add this as a
boolean flag in extra parameters (``{"federated_k8s": true}``). When enabled,
the hook will fetch a JWT token from Kubernetes and exchange it for a
Databricks OAuth token using the `OIDC token exchange API
<https://docs.databricks.com/aws/en/dev-tools/auth/oauth-federation-exchange.html>`_.
This authentication method only works when Airflow is running inside a
Kubernetes cluster (e.g., AWS EKS, Azure AKS, Goog [...]
diff --git a/providers/databricks/docs/index.rst
b/providers/databricks/docs/index.rst
index fb5f5193acd..c95e238d777 100644
--- a/providers/databricks/docs/index.rst
+++ b/providers/databricks/docs/index.rst
@@ -126,12 +126,13 @@ You can install such cross-provider dependencies when
installing from PyPI. For
.. code-block:: bash
- pip install apache-airflow-providers-databricks[google]
+ pip install apache-airflow-providers-databricks[amazon]
==============================================================================================================
===============
Dependent package
Extra
==============================================================================================================
===============
+`apache-airflow-providers-amazon
<https://airflow.apache.org/docs/apache-airflow-providers-amazon>`_
``amazon``
`apache-airflow-providers-google
<https://airflow.apache.org/docs/apache-airflow-providers-google>`_
``google``
`apache-airflow-providers-openlineage
<https://airflow.apache.org/docs/apache-airflow-providers-openlineage>`_
``openlineage``
==============================================================================================================
===============
@@ -151,6 +152,7 @@ Install them when installing from PyPI. For example:
Extra Dependencies
==================
================================================================================================================================================================
``avro`` ``fastavro>=1.9.0; python_version<"3.14"``,
``fastavro>=1.10.0; python_version>="3.12" and python_version<"3.14"``,
``fastavro>=1.12.1; python_version>="3.14"``
+``amazon`` ``apache-airflow-providers-amazon>=9.22.0``
``azure-identity`` ``azure-identity>=1.3.1``
``fab`` ``apache-airflow-providers-fab>=2.2.0``
``google`` ``apache-airflow-providers-google>=10.24.0``
diff --git a/providers/databricks/pyproject.toml
b/providers/databricks/pyproject.toml
index 6b6e846bcc6..80da493a144 100644
--- a/providers/databricks/pyproject.toml
+++ b/providers/databricks/pyproject.toml
@@ -83,6 +83,9 @@ dependencies = [
'fastavro>=1.10.0; python_version>="3.12" and python_version<"3.14"',
'fastavro>=1.12.1; python_version>="3.14"',
]
+"amazon" = [
+ "apache-airflow-providers-amazon>=9.22.0"
+]
"azure-identity" = [
"azure-identity>=1.3.1",
]
@@ -110,6 +113,7 @@ dev = [
"apache-airflow",
"apache-airflow-task-sdk",
"apache-airflow-devel-common",
+ "apache-airflow-providers-amazon",
"apache-airflow-providers-common-compat",
"apache-airflow-providers-common-sql",
"apache-airflow-providers-google",
diff --git
a/providers/databricks/src/airflow/providers/databricks/hooks/databricks_base.py
b/providers/databricks/src/airflow/providers/databricks/hooks/databricks_base.py
index 07f5500f7b3..ad48f09a936 100644
---
a/providers/databricks/src/airflow/providers/databricks/hooks/databricks_base.py
+++
b/providers/databricks/src/airflow/providers/databricks/hooks/databricks_base.py
@@ -84,6 +84,11 @@ DEFAULT_K8S_SERVICE_ACCOUNT_TOKEN_PATH =
"/var/run/secrets/kubernetes.io/service
DEFAULT_K8S_NAMESPACE_PATH =
"/var/run/secrets/kubernetes.io/serviceaccount/namespace"
K8S_CA_CERT_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
+# AWS IAM OIDC token federation (STS Outbound Identity Federation)
+# https://docs.databricks.com/aws/en/dev-tools/auth/provider-aws-iam
+DEFAULT_AWS_JWT_AUDIENCE = "databricks"
+DEFAULT_AWS_WEB_IDENTITY_TOKEN_DURATION = 300
+
# RFC 8693 token exchange data template
TOKEN_EXCHANGE_DATA = {
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
@@ -137,6 +142,10 @@ class BaseDatabricksHook(BaseHook):
"service_principal_oauth",
"federated_k8s",
"federated_token_provider",
+ "federated_aws",
+ "aws_conn_id",
+ "aws_jwt_audience",
+ "aws_web_identity_token_duration",
"k8s_token_path",
"k8s_namespace_path",
"k8s_projected_volume_token_path",
@@ -917,17 +926,24 @@ class BaseDatabricksHook(BaseHook):
"""
Resolve the OIDC JWT to exchange for a Databricks token (RFC 8693
``subject_token``).
- Two subject-token sources are supported:
+ Three subject-token sources are supported:
* ``federated_token_provider`` -- a dotted path to a ``Callable[[],
str]`` that returns
the JWT. The token is obtained in-process and never written to disk,
so a control
plane can vend a short-lived, per-workload identity token for the
exchange. ``client_id``
is optional here: supply it in the extra for a service principal
federation policy, or
omit it for an account-wide federation policy.
+ * AWS IAM (``federated_aws``) -- mint an AWS-signed OIDC JWT via AWS
STS
+ ``GetWebIdentityToken``, configured entirely from the connection
extra
+
(``aws_conn_id``/``aws_jwt_audience``/``aws_web_identity_token_duration``).
``client_id`` is
+ optional, as with a supplied provider.
* Kubernetes service account (default) -- read from the pod.
``client_id`` is required
because Kubernetes service account tokens cannot carry custom
claims, so only
service-principal-level federation is possible; it is validated
before the token is read.
+ If more than one is configured, precedence is: supplied provider, then
AWS, then Kubernetes
+ (matching the dispatch order in :meth:`_get_token`).
+
:return: a ``(subject_token, client_id)`` tuple; ``client_id`` is
``None`` when the exchange
should omit it (account-wide federation policy).
"""
@@ -936,6 +952,8 @@ class BaseDatabricksHook(BaseHook):
return self._resolve_supplied_subject_token(provider),
self.databricks_conn.extra_dejson.get(
"client_id"
)
+ if self._is_aws_federation():
+ return self._get_aws_subject_token(),
self.databricks_conn.extra_dejson.get("client_id")
client_id = self._get_required_client_id()
return self._get_k8s_jwt_token(), client_id
@@ -948,9 +966,57 @@ class BaseDatabricksHook(BaseHook):
loop = asyncio.get_running_loop()
subject_token = await loop.run_in_executor(None,
self._resolve_supplied_subject_token, provider)
return subject_token,
self.databricks_conn.extra_dejson.get("client_id")
+ if self._is_aws_federation():
+ loop = asyncio.get_running_loop()
+ subject_token = await loop.run_in_executor(None,
self._get_aws_subject_token)
+ return subject_token,
self.databricks_conn.extra_dejson.get("client_id")
client_id = self._get_required_client_id()
return await self._a_get_k8s_jwt_token(), client_id
+ def _is_aws_federation(self) -> bool:
+ """Return whether the connection is configured for AWS IAM OIDC token
federation."""
+ return self.databricks_conn.login == "federated_aws" or
self.databricks_conn.extra_dejson.get(
+ "federated_aws", False
+ )
+
+ def _get_aws_subject_token(self) -> str:
+ """
+ Mint an AWS-signed OIDC JWT for the exchange via AWS STS
``GetWebIdentityToken``.
+
+ Reads
``aws_conn_id``/``aws_jwt_audience``/``aws_web_identity_token_duration`` from
the connection
+ extra. The minted JWT's ``sub`` claim is the caller's IAM role ARN,
matching the Databricks
+ federation policy. See
https://docs.databricks.com/aws/en/dev-tools/auth/provider-aws-iam.
+ """
+ try:
+ from airflow.providers.amazon.aws.hooks.sts import StsHook
+ except ImportError as e:
+ raise AirflowOptionalProviderFeatureException(
+ "The 'apache-airflow-providers-amazon' package (>=9.22.0) is
required for AWS OIDC "
+ "token federation. Install it with: pip install
'apache-airflow-providers-amazon>=9.22.0'"
+ ) from e
+
+ extra = self.databricks_conn.extra_dejson
+ aws_conn_id = extra.get("aws_conn_id", "aws_default")
+ audience = extra.get("aws_jwt_audience", DEFAULT_AWS_JWT_AUDIENCE)
+ duration_seconds = int(
+ extra.get("aws_web_identity_token_duration",
DEFAULT_AWS_WEB_IDENTITY_TOKEN_DURATION)
+ )
+
+ sts_client = StsHook(aws_conn_id=aws_conn_id).get_conn()
+ if not hasattr(sts_client, "get_web_identity_token"):
+ raise AirflowOptionalProviderFeatureException(
+ "The installed AWS SDK does not support
'sts:GetWebIdentityToken'. AWS IAM outbound "
+ "identity federation requires boto3>=1.41.0 / botocore>=1.41.0
"
+ "(apache-airflow-providers-amazon>=9.22.0)."
+ )
+
+ response = sts_client.get_web_identity_token(
+ Audience=[audience],
+ SigningAlgorithm="RS256",
+ DurationSeconds=duration_seconds,
+ )
+ return response["WebIdentityToken"]
+
def _resolve_supplied_subject_token(self, provider: str) -> str:
"""
Import and invoke the ``federated_token_provider`` callable, returning
its OIDC JWT.
@@ -983,9 +1049,9 @@ class BaseDatabricksHook(BaseHook):
"""
Get a Databricks OAuth token by exchanging a federated OIDC JWT.
- Uses RFC 8693 token exchange to convert an OIDC subject token --
supplied either by a
- ``federated_token_provider`` callable or by the pod's Kubernetes
service account (see
- :meth:`_get_federation_subject_token`) -- into a Databricks OAuth
token.
+ Uses RFC 8693 token exchange to convert an OIDC subject token --
supplied by a
+ ``federated_token_provider`` callable, AWS STS (``federated_aws``), or
the pod's Kubernetes
+ service account (see :meth:`_get_federation_subject_token`) -- into a
Databricks OAuth token.
:param resource: Databricks OIDC token exchange URL
:return: Databricks OAuth access token
@@ -1167,6 +1233,9 @@ class BaseDatabricksHook(BaseHook):
if self.databricks_conn.extra_dejson.get("federated_token_provider"):
self.log.debug("Using OIDC token federation with a supplied token
provider.")
return
self._get_federated_databricks_token(self._get_oidc_token_service_url())
+ if self._is_aws_federation():
+ self.log.debug("Using AWS IAM OIDC token federation.")
+ return
self._get_federated_databricks_token(self._get_oidc_token_service_url())
if self.databricks_conn.login == "federated_k8s" or
self.databricks_conn.extra_dejson.get(
"federated_k8s", False
):
@@ -1207,6 +1276,9 @@ class BaseDatabricksHook(BaseHook):
if self.databricks_conn.extra_dejson.get("federated_token_provider"):
self.log.debug("Using OIDC token federation with a supplied token
provider.")
return await
self._a_get_federated_databricks_token(self._get_oidc_token_service_url())
+ if self._is_aws_federation():
+ self.log.debug("Using AWS IAM OIDC token federation.")
+ return await
self._a_get_federated_databricks_token(self._get_oidc_token_service_url())
if self.databricks_conn.login == "federated_k8s" or
self.databricks_conn.extra_dejson.get(
"federated_k8s", False
):
diff --git
a/providers/databricks/tests/unit/databricks/hooks/test_databricks_base.py
b/providers/databricks/tests/unit/databricks/hooks/test_databricks_base.py
index ed7ed054df2..9b0e340ef12 100644
--- a/providers/databricks/tests/unit/databricks/hooks/test_databricks_base.py
+++ b/providers/databricks/tests/unit/databricks/hooks/test_databricks_base.py
@@ -30,7 +30,7 @@ from requests.auth import HTTPBasicAuth
from tenacity import AsyncRetrying, Future, RetryError, retry_if_exception,
stop_after_attempt, wait_fixed
from airflow.models import Connection
-from airflow.providers.common.compat.sdk import AirflowException
+from airflow.providers.common.compat.sdk import AirflowException,
AirflowOptionalProviderFeatureException
from airflow.providers.databricks.hooks.databricks_base import (
DEFAULT_AZURE_CREDENTIAL_SETTING_KEY,
DEFAULT_DATABRICKS_SCOPE,
@@ -1358,6 +1358,45 @@ class TestBaseDatabricksHook:
mock_k8s.assert_not_called()
assert mock_post.call_args.kwargs["data"]["subject_token"] ==
_SUPPLIED_JWT
+ @mock.patch("airflow.providers.amazon.aws.hooks.sts.StsHook")
+ @mock.patch("requests.post")
+ def test_federated_aws_takes_precedence_over_federated_k8s(self,
mock_post, mock_sts_hook):
+ """With both configured, the AWS path wins and the Kubernetes disk
path is never used."""
+
mock_sts_hook.return_value.get_conn.return_value.get_web_identity_token.return_value
= {
+ "WebIdentityToken": "aws_signed_jwt"
+ }
+ db_response = mock.Mock(spec=Response)
+ db_response.json.return_value = {
+ "access_token": "databricks_token",
+ "expires_in": 3600,
+ "token_type": "Bearer",
+ }
+ db_response.raise_for_status.return_value = None
+ mock_post.return_value = db_response
+
+ conn = Connection(
+ conn_id=DEFAULT_CONN_ID,
+ conn_type="databricks",
+ host="my-workspace.cloud.databricks.com",
+ login=None,
+ password=None,
+ extra=json.dumps(
+ {
+ "federated_aws": True,
+ "federated_k8s": True,
+ "client_id": "sp-client-id",
+ }
+ ),
+ )
+ hook = BaseDatabricksHook()
+ hook.databricks_conn = conn
+ hook.user_agent_header = {"User-Agent": "test-agent"}
+
+ with mock.patch.object(hook, "_get_k8s_jwt_token") as mock_k8s:
+ assert hook._get_token() == "databricks_token"
+ mock_k8s.assert_not_called()
+ assert mock_post.call_args.kwargs["data"]["subject_token"] ==
"aws_signed_jwt"
+
@pytest.mark.asyncio
@mock.patch("aiohttp.ClientSession.post")
async def test_a_get_token_with_supplied_provider(self, mock_post):
@@ -1693,6 +1732,163 @@ class TestBaseDatabricksHook:
assert token == "databricks_token"
+ @mock.patch("airflow.providers.amazon.aws.hooks.sts.StsHook")
+ @mock.patch("requests.post")
+ def test_get_token_with_federated_aws_login(self, mock_post,
mock_sts_hook):
+ """_get_token with login='federated_aws' mints an STS token and
exchanges it (service principal)."""
+
mock_sts_hook.return_value.get_conn.return_value.get_web_identity_token.return_value
= {
+ "WebIdentityToken": "aws_signed_jwt"
+ }
+ db_response = mock.Mock(spec=Response)
+ db_response.json.return_value = {
+ "access_token": "databricks_token",
+ "expires_in": 3600,
+ "token_type": "Bearer",
+ }
+ db_response.raise_for_status.return_value = None
+ mock_post.return_value = db_response
+
+ conn = Connection(
+ conn_id=DEFAULT_CONN_ID,
+ conn_type="databricks",
+ host="my-workspace.cloud.databricks.com",
+ login="federated_aws",
+ password=None,
+ extra=json.dumps({"client_id": "test-client-id"}),
+ )
+ hook = BaseDatabricksHook()
+ hook.databricks_conn = conn
+ hook.user_agent_header = {"User-Agent": "test-agent"}
+
+ assert hook._get_token() == "databricks_token"
+ mock_sts_hook.assert_called_once_with(aws_conn_id="aws_default")
+ data = mock_post.call_args.kwargs["data"]
+ assert data["subject_token"] == "aws_signed_jwt"
+ assert data["grant_type"] ==
"urn:ietf:params:oauth:grant-type:token-exchange"
+ assert data["client_id"] == "test-client-id"
+
+ @mock.patch("airflow.providers.amazon.aws.hooks.sts.StsHook")
+ @mock.patch("requests.post")
+ def test_get_token_with_federated_aws_extra_account_wide(self, mock_post,
mock_sts_hook):
+ """federated_aws via extra flag: custom settings honored, no client_id
(account-wide federation)."""
+ mock_client = mock_sts_hook.return_value.get_conn.return_value
+ mock_client.get_web_identity_token.return_value = {"WebIdentityToken":
"aws_signed_jwt"}
+ db_response = mock.Mock(spec=Response)
+ db_response.json.return_value = {
+ "access_token": "databricks_token",
+ "expires_in": 3600,
+ "token_type": "Bearer",
+ }
+ db_response.raise_for_status.return_value = None
+ mock_post.return_value = db_response
+
+ conn = Connection(
+ conn_id=DEFAULT_CONN_ID,
+ conn_type="databricks",
+ host="my-workspace.cloud.databricks.com",
+ login=None,
+ password=None,
+ extra=json.dumps(
+ {
+ "federated_aws": True,
+ "aws_conn_id": "aws_prod",
+ "aws_jwt_audience": "databricks-prod",
+ "aws_web_identity_token_duration": 900,
+ }
+ ),
+ )
+ hook = BaseDatabricksHook()
+ hook.databricks_conn = conn
+ hook.user_agent_header = {"User-Agent": "test-agent"}
+
+ assert hook._get_token() == "databricks_token"
+ mock_sts_hook.assert_called_once_with(aws_conn_id="aws_prod")
+ mock_client.get_web_identity_token.assert_called_once_with(
+ Audience=["databricks-prod"], SigningAlgorithm="RS256",
DurationSeconds=900
+ )
+ data = mock_post.call_args.kwargs["data"]
+ assert data["subject_token"] == "aws_signed_jwt"
+ assert "client_id" not in data
+
+ @mock.patch("airflow.providers.amazon.aws.hooks.sts.StsHook")
+ def test_get_aws_subject_token_old_boto3(self, mock_sts_hook):
+ """federated_aws errors clearly when the installed AWS SDK lacks
GetWebIdentityToken."""
+ mock_sts_hook.return_value.get_conn.return_value =
mock.Mock(spec=["get_caller_identity"])
+ conn = Connection(
+ conn_id=DEFAULT_CONN_ID,
+ conn_type="databricks",
+ host="my-workspace.cloud.databricks.com",
+ login="federated_aws",
+ password=None,
+ extra=json.dumps({"client_id": "test-client-id"}),
+ )
+ hook = BaseDatabricksHook()
+ hook.databricks_conn = conn
+
+ with pytest.raises(
+ AirflowOptionalProviderFeatureException, match="does not support
'sts:GetWebIdentityToken'"
+ ):
+ hook._get_aws_subject_token()
+
+ def test_get_aws_subject_token_amazon_not_installed(self):
+ """federated_aws errors clearly when the amazon provider is not
installed."""
+ conn = Connection(
+ conn_id=DEFAULT_CONN_ID,
+ conn_type="databricks",
+ host="my-workspace.cloud.databricks.com",
+ login="federated_aws",
+ password=None,
+ extra=json.dumps({"client_id": "test-client-id"}),
+ )
+ hook = BaseDatabricksHook()
+ hook.databricks_conn = conn
+
+ with mock.patch.dict("sys.modules",
{"airflow.providers.amazon.aws.hooks.sts": None}):
+ with pytest.raises(
+ AirflowOptionalProviderFeatureException,
match="apache-airflow-providers-amazon"
+ ):
+ hook._get_aws_subject_token()
+
+ @pytest.mark.asyncio
+ @mock.patch("airflow.providers.amazon.aws.hooks.sts.StsHook")
+ @mock.patch("aiohttp.ClientSession.post")
+ @time_machine.travel("2025-07-12 12:00:00", tick=False)
+ async def test_a_get_token_with_federated_aws(self, mock_post,
mock_sts_hook):
+ """Async _a_get_token with federated_aws mints and exchanges the STS
token."""
+
mock_sts_hook.return_value.get_conn.return_value.get_web_identity_token.return_value
= {
+ "WebIdentityToken": "aws_signed_jwt"
+ }
+ db_response = mock.AsyncMock()
+ db_response.__aenter__.return_value = db_response
+ db_response.__aexit__.return_value = None
+ db_response.raise_for_status.return_value = None
+ db_response.json.return_value = {
+ "access_token": "async_databricks_token",
+ "expires_in": 3600,
+ "token_type": "Bearer",
+ }
+ mock_post.return_value = db_response
+
+ conn = Connection(
+ conn_id=DEFAULT_CONN_ID,
+ conn_type="databricks",
+ host="my-workspace.cloud.databricks.com",
+ login="federated_aws",
+ password=None,
+ extra=json.dumps({"client_id": "test-client-id"}),
+ )
+ hook = BaseDatabricksHook()
+ hook.databricks_conn = conn
+ hook.user_agent_header = {"User-Agent": "test-agent"}
+
+ async with aiohttp.ClientSession() as session:
+ hook._session = session
+ token = await hook._a_get_token()
+
+ assert token == "async_databricks_token"
+ assert mock_post.call_args.kwargs["data"]["subject_token"] ==
"aws_signed_jwt"
+ assert mock_post.call_args.kwargs["data"]["client_id"] ==
"test-client-id"
+
@pytest.mark.asyncio
@mock.patch("aiohttp.ClientSession.post")
@time_machine.travel("2025-07-12 12:00:00", tick=False)