kaxil commented on code in PR #75810:
URL: https://github.com/apache/airflow/pull/75810#discussion_r4467410122
##########
providers/snowflake/tests/unit/snowflake/hooks/test_sql_api.py:
##########
@@ -539,6 +539,23 @@ def
test_get_headers_uses_key_pair_for_workload_identity_connection_with_private
result = hook.get_headers()
assert result["X-Snowflake-Authorization-Token-Type"] == "KEYPAIR_JWT"
+
@mock.patch("airflow.providers.snowflake.utils._rest_auth._fetch_gcp_identity_token",
autospec=True)
+ @mock.patch(f"{HOOK_PATH}.get_private_key", autospec=True,
return_value=None)
+ @mock.patch(f"{HOOK_PATH}._get_conn_params", autospec=True)
+ def test_get_headers_uses_workload_identity_without_private_key(
+ self, mock_conn_param, mock_private_key, mock_fetch_token
+ ):
+ mock_conn_param.return_value = {
+ **CONN_PARAMS,
+ "region": "us-east-2.aws",
+ "workload_identity_provider": "GCP",
Review Comment:
With the new `WORKLOAD_IDENTITY` check this test never reaches WIF:
`CONN_PARAMS` sets `authenticator: "snowflake"`, so `get_headers()` raises the
"none is configured" `ValueError`.
`test_request_sends_workload_identity_headers` in `test_cortex_agent.py` fails
the same way, since it sets no authenticator at all. Adding `"authenticator":
"WORKLOAD_IDENTITY"` to both gets them passing again.
##########
providers/snowflake/docs/connections/snowflake.rst:
##########
@@ -189,3 +189,28 @@ and is granted a role with access to the target objects.
"workload_identity_provider": "GCP"
}
}'
+
+``SnowflakeSqlApiHook``, ``SnowflakeCortexAgentHook`` and the
``pydanticai_snowflake`` connection type call
+Snowflake's REST APIs, which accept the workload's identity token directly
instead of a connector session.
+The token is requested from the cloud and reused until shortly before it
expires. A connection that also
+sets a private key uses key-pair authentication for these REST hooks. Each
provider needs its cloud's client
+library installed:
+
+* ``AWS``: ``boto3`` with ``botocore>=1.41.0`` for ``sts:GetWebIdentityToken``
support.
+ The workload's IAM role must be allowed to
+ call it, and AWS outbound identity federation must be enabled for the
account. The call is not available on
Review Comment:
`SnowflakeHook` doesn't need outbound identity federation on AWS: connector
4.8.1 signs a `GetCallerIdentity` request by default and only calls
`get_web_identity_token` when `workload_identity_aws_use_outbound_token` or
`SNOWFLAKE_ENABLE_AWS_WIF_OUTBOUND_TOKEN` is set, and
`_add_workload_identity_config` passes neither. So on an account without it,
`SnowflakeHook` works and these hooks fail, which the "same connection works
for" line up top doesn't hint at. Worth a sentence saying only the REST hooks
need it?
##########
providers/snowflake/tests/unit/snowflake/utils/test__rest_auth.py:
##########
@@ -40,6 +48,16 @@
"password": "my_pat_token_value",
}
CONN_PARAMS_KEYPAIR = {"account": "airflow", "authenticator": "snowflake",
"user": "user"}
Review Comment:
With `authenticator: "snowflake"` here,
`test_get_token_workload_identity_with_private_key_uses_key_pair`
(`{**CONN_PARAMS_KEYPAIR, "workload_identity_provider": "AWS"}`) no longer
qualifies for WIF, so it would pass even if WIF were picked ahead of the
private key. Same for
`test_get_headers_uses_key_pair_for_workload_identity_connection_with_private_key`
in `test_sql_api.py`. Setting `authenticator` to `WORKLOAD_IDENTITY` in those
two would keep them testing the precedence.
##########
providers/snowflake/src/airflow/providers/snowflake/utils/_rest_auth.py:
##########
@@ -163,6 +242,104 @@ def build_auth_headers(self) -> dict[str, str]:
}
+def _validate_wif_host(conn_config: dict[str, Any]) -> None:
+ """Restrict ambient workload credentials to trusted Snowflake
destinations."""
+ host =
get_cortex_base_url(conn_config).removeprefix("https://").lower().removesuffix(".")
+ suffixes = ["snowflakecomputing.com", "snowflakecomputing.cn",
"snowflakecomputing.mil"]
+ suffixes.extend(
+ entry.strip().lower().strip(".")
+ for entry in os.environ.get("SNOWFLAKE_WIF_ALLOWED_HOST_SUFFIXES",
"").split(",")
+ if entry.strip()
+ )
+ dns_name =
r"[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?(?:\.[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?)*"
+ if (
+ len(host) <= 253
+ and re.fullmatch(dns_name, host)
+ and any(
+ re.fullmatch(dns_name, suffix) and (host == suffix or
host.endswith(f".{suffix}"))
+ for suffix in suffixes
+ )
+ ):
+ return
+ raise ValueError("Workload Identity Federation requires a recognized
Snowflake host.")
+
+
+def _fetch_aws_identity_token() -> str:
+ """Return the JWT from regional STS for the workload's AWS identity."""
+ try:
+ import boto3
+ from botocore.session import get_session
+ from botocore.utils import InstanceMetadataRegionFetcher
+ except ImportError as e:
+ raise AirflowOptionalProviderFeatureException(
+ "Workload Identity Federation with the AWS provider requires
boto3. Install boto3."
+ ) from e
+ region = (
+ os.environ.get("AWS_REGION")
+ or os.environ.get("AWS_DEFAULT_REGION")
+ or InstanceMetadataRegionFetcher().retrieve_region()
+ )
+ if not region:
+ raise ValueError(
+ "AWS workload identity requires a region: set AWS_REGION or
AWS_DEFAULT_REGION, "
+ "or enable EC2 instance metadata region discovery."
+ )
+ session = get_session()
+ session.set_config_variable("sts_regional_endpoints", "regional")
+ sts = boto3.Session(botocore_session=session).client("sts",
region_name=region)
+ if not hasattr(sts, "get_web_identity_token"):
+ raise AirflowOptionalProviderFeatureException(
+ "Workload Identity Federation with the AWS provider requires a
boto3 version that "
+ "supports sts:GetWebIdentityToken (botocore>=1.41.0). Upgrade
boto3."
+ )
+ response = sts.get_web_identity_token(Audience=[WIF_AUDIENCE],
SigningAlgorithm="ES384")
+ return response["WebIdentityToken"]
+
+
+def _fetch_azure_identity_token() -> str:
+ """
+ Return an Entra access token for Snowflake's application, issued to the
workload's identity.
+
+ Uses the AKS workload identity when the pod has one (the Azure Workload
Identity webhook sets
+ ``AZURE_CLIENT_ID``, ``AZURE_TENANT_ID`` and
``AZURE_FEDERATED_TOKEN_FILE``), and the managed
+ identity otherwise. A user-assigned managed identity is selected with
+ ``MANAGED_IDENTITY_CLIENT_ID``.
+ """
+ try:
+ from azure.identity import ManagedIdentityCredential,
WorkloadIdentityCredential
+ except ImportError as e:
+ raise AirflowOptionalProviderFeatureException(
+ "Workload Identity Federation with the AZURE provider requires
azure-identity. "
+ "Install apache-airflow-providers-snowflake[microsoft.azure]."
+ ) from e
+ credential: ManagedIdentityCredential | WorkloadIdentityCredential
+ if all(
+ os.environ.get(name) for name in ("AZURE_CLIENT_ID",
"AZURE_TENANT_ID", "AZURE_FEDERATED_TOKEN_FILE")
+ ) and os.path.exists(os.environ["AZURE_FEDERATED_TOKEN_FILE"]):
+ credential = WorkloadIdentityCredential()
+ else:
+ credential =
ManagedIdentityCredential(client_id=os.environ.get("MANAGED_IDENTITY_CLIENT_ID"))
Review Comment:
This one's on me, I missed it when I suggested the file check:
`ManagedIdentityCredential()` picks `WorkloadIdentityCredential` by itself
whenever `AZURE_AUTHORITY_HOST`, `AZURE_TENANT_ID` and
`AZURE_FEDERATED_TOKEN_FILE` are set (azure-identity 1.27.0), and it doesn't
look at the file. The AKS webhook sets all three, so a missing mount fails here
anyway instead of falling back to IMDS like the connector does. The only
opt-out I found is the private `_exclude_workload_identity_credential=True`
kwarg, and the test mocks both classes so it can't see this. I'd probably just
drop the existence check, unless you want to take on the private kwarg?
##########
providers/snowflake/src/airflow/providers/snowflake/utils/_rest_auth.py:
##########
@@ -117,18 +141,18 @@ def get_token(self) -> SnowflakeRestToken:
)
return SnowflakeRestToken(token=pat,
token_type="PROGRAMMATIC_ACCESS_TOKEN")
- if self._private_key is None:
+ if not self._private_key_loaded:
self._private_key = self._private_key_loader()
+ self._private_key_loaded = True
if self._private_key is None:
- if conn_config.get("workload_identity_provider"):
- raise ValueError(
- "Workload identity federation is not supported for
Snowflake REST APIs; use "
- "OAuth, PAT, or key-pair authentication instead. A
connection that also "
- "sets a private key uses key-pair authentication."
- )
+ if (
+ conn_config.get("authenticator") or ""
+ ).upper() == "WORKLOAD_IDENTITY" and
conn_config.get("workload_identity_provider"):
+ return self._get_workload_identity_token(conn_config)
raise ValueError(
"Snowflake REST API authentication requires an OAuth
access token, a "
- "Programmatic Access Token (PAT), or a private key for
key-pair JWT auth; "
+ "Programmatic Access Token (PAT), a private key for
key-pair JWT auth, or a "
+ "workload_identity_provider for Workload Identity
Federation; "
Review Comment:
A connection that sets `workload_identity_provider` but not `authenticator`
now ends up here, and the message tells them to set the extra they already
have. The connector raises "workload_identity_provider was set but
authenticator was not set to WORKLOAD_IDENTITY" for that case. Could this do
the same?
--
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]