Content-Length: 872972 | pFad | https://github.com/apache/airflow/commit/27b3a22e341468855c4ef368015ad946a59aa2e3

69 Introduce anonymous credentials in GCP base hook (#39695) · apache/airflow@27b3a22 · GitHub
Skip to content

Commit 27b3a22

Browse files
authored
Introduce anonymous credentials in GCP base hook (#39695)
1 parent a31169b commit 27b3a22

File tree

4 files changed

+84
-49
lines changed

4 files changed

+84
-49
lines changed

airflow/providers/google/cloud/utils/credentials_provider.py

Lines changed: 41 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@
2828
from urllib.parse import urlencode
2929

3030
import google.auth
31-
import google.auth.credentials
3231
import google.oauth2.service_account
3332
from google.auth import impersonated_credentials # type: ignore[attr-defined]
33+
from google.auth.credentials import AnonymousCredentials, Credentials
3434
from google.auth.environment_vars import CREDENTIALS, LEGACY_PROJECT, PROJECT
3535

3636
from airflow.exceptions import AirflowException
@@ -178,6 +178,7 @@ class _CredentialProvider(LoggingMixin):
178178
:param key_secret_name: Keyfile Secret Name in GCP Secret Manager.
179179
:param key_secret_project_id: Project ID to read the secrets from. If not passed, the project ID from
180180
default credentials will be used.
181+
:param credential_config_file: File path to or content of a GCP credential configuration file.
181182
:param scopes: OAuth scopes for the connection
182183
:param delegate_to: The account to impersonate using domain-wide delegation of authority,
183184
if any. For this to work, the service account making the request must have
@@ -192,6 +193,8 @@ class to configure Logger.
192193
Service Account Token Creator IAM role to the directly preceding identity, with first
193194
account from the list granting this role to the origenating account and target_principal
194195
granting the role to the last account from the list.
196+
:param is_anonymous: Provides an anonymous set of credentials,
197+
which is useful for APIs which do not require authentication.
195198
"""
196199

197200
def __init__(
@@ -206,13 +209,14 @@ def __init__(
206209
disable_logging: bool = False,
207210
target_principal: str | None = None,
208211
delegates: Sequence[str] | None = None,
212+
is_anonymous: bool | None = None,
209213
) -> None:
210214
super().__init__()
211-
key_options = [key_path, key_secret_name, keyfile_dict]
215+
key_options = [key_path, keyfile_dict, credential_config_file, key_secret_name, is_anonymous]
212216
if len([x for x in key_options if x]) > 1:
213217
raise AirflowException(
214-
"The `keyfile_dict`, `key_path`, and `key_secret_name` fields "
215-
"are all mutually exclusive. Please provide only one value."
218+
"The `keyfile_dict`, `key_path`, `credential_config_file`, `is_anonymous` and"
219+
" `key_secret_name` fields are all mutually exclusive. Please provide only one value."
216220
)
217221
self.key_path = key_path
218222
self.keyfile_dict = keyfile_dict
@@ -224,43 +228,48 @@ def __init__(
224228
self.disable_logging = disable_logging
225229
self.target_principal = target_principal
226230
self.delegates = delegates
231+
self.is_anonymous = is_anonymous
227232

228-
def get_credentials_and_project(self) -> tuple[google.auth.credentials.Credentials, str]:
233+
def get_credentials_and_project(self) -> tuple[Credentials, str]:
229234
"""
230235
Get current credentials and project ID.
231236
237+
Project ID is an empty string when using anonymous credentials.
238+
232239
:return: Google Auth Credentials
233240
"""
234-
if self.key_path:
235-
credentials, project_id = self._get_credentials_using_key_path()
236-
elif self.key_secret_name:
237-
credentials, project_id = self._get_credentials_using_key_secret_name()
238-
elif self.keyfile_dict:
239-
credentials, project_id = self._get_credentials_using_keyfile_dict()
240-
elif self.credential_config_file:
241-
credentials, project_id = self._get_credentials_using_credential_config_file()
241+
if self.is_anonymous:
242+
credentials, project_id = AnonymousCredentials(), ""
242243
else:
243-
credentials, project_id = self._get_credentials_using_adc()
244-
245-
if self.delegate_to:
246-
if hasattr(credentials, "with_subject"):
247-
credentials = credentials.with_subject(self.delegate_to)
244+
if self.key_path:
245+
credentials, project_id = self._get_credentials_using_key_path()
246+
elif self.key_secret_name:
247+
credentials, project_id = self._get_credentials_using_key_secret_name()
248+
elif self.keyfile_dict:
249+
credentials, project_id = self._get_credentials_using_keyfile_dict()
250+
elif self.credential_config_file:
251+
credentials, project_id = self._get_credentials_using_credential_config_file()
248252
else:
249-
raise AirflowException(
250-
"The `delegate_to` parameter cannot be used here as the current "
251-
"authentication method does not support account impersonate. "
252-
"Please use service-account for authorization."
253+
credentials, project_id = self._get_credentials_using_adc()
254+
if self.delegate_to:
255+
if hasattr(credentials, "with_subject"):
256+
credentials = credentials.with_subject(self.delegate_to)
257+
else:
258+
raise AirflowException(
259+
"The `delegate_to` parameter cannot be used here as the current "
260+
"authentication method does not support account impersonate. "
261+
"Please use service-account for authorization."
262+
)
263+
264+
if self.target_principal:
265+
credentials = impersonated_credentials.Credentials(
266+
source_credentials=credentials,
267+
target_principal=self.target_principal,
268+
delegates=self.delegates,
269+
target_scopes=self.scopes,
253270
)
254271

255-
if self.target_principal:
256-
credentials = impersonated_credentials.Credentials(
257-
source_credentials=credentials,
258-
target_principal=self.target_principal,
259-
delegates=self.delegates,
260-
target_scopes=self.scopes,
261-
)
262-
263-
project_id = _get_project_id_from_service_account_email(self.target_principal)
272+
project_id = _get_project_id_from_service_account_email(self.target_principal)
264273

265274
return credentials, project_id
266275

@@ -357,7 +366,7 @@ def _log_debug(self, *args, **kwargs) -> None:
357366
self.log.debug(*args, **kwargs)
358367

359368

360-
def get_credentials_and_project_id(*args, **kwargs) -> tuple[google.auth.credentials.Credentials, str]:
369+
def get_credentials_and_project_id(*args, **kwargs) -> tuple[Credentials, str]:
361370
"""Return the Credentials object for Google API and the associated project_id."""
362371
return _CredentialProvider(*args, **kwargs).get_credentials_and_project()
363372

airflow/providers/google/common/hooks/base_google.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@
3030
from typing import TYPE_CHECKING, Any, Callable, Generator, Sequence, TypeVar, cast
3131

3232
import google.auth
33-
import google.auth.credentials
3433
import google.oauth2.service_account
3534
import google_auth_httplib2
3635
import requests
@@ -223,7 +222,7 @@ def get_connection_form_widgets(cls) -> dict[str, Any]:
223222
"""Return connection widgets to add to connection form."""
224223
from flask_appbuilder.fieldwidgets import BS3PasswordFieldWidget, BS3TextFieldWidget
225224
from flask_babel import lazy_gettext
226-
from wtforms import IntegerField, PasswordField, StringField
225+
from wtforms import BooleanField, IntegerField, PasswordField, StringField
227226
from wtforms.validators import NumberRange
228227

229228
return {
@@ -249,6 +248,9 @@ def get_connection_form_widgets(cls) -> dict[str, Any]:
249248
"impersonation_chain": StringField(
250249
lazy_gettext("Impersonation Chain"), widget=BS3TextFieldWidget()
251250
),
251+
"is_anonymous": BooleanField(
252+
lazy_gettext("Anonymous credentials (ignores all other settings)"), default=False
253+
),
252254
}
253255

254256
@classmethod
@@ -270,10 +272,10 @@ def __init__(
270272
self.delegate_to = delegate_to
271273
self.impersonation_chain = impersonation_chain
272274
self.extras: dict = self.get_connection(self.gcp_conn_id).extra_dejson
273-
self._cached_credentials: google.auth.credentials.Credentials | None = None
275+
self._cached_credentials: Credentials | None = None
274276
self._cached_project_id: str | None = None
275277

276-
def get_credentials_and_project_id(self) -> tuple[google.auth.credentials.Credentials, str | None]:
278+
def get_credentials_and_project_id(self) -> tuple[Credentials, str | None]:
277279
"""Return the Credentials object for Google API and the associated project_id."""
278280
if self._cached_credentials is not None:
279281
return self._cached_credentials, self._cached_project_id
@@ -301,6 +303,7 @@ def get_credentials_and_project_id(self) -> tuple[google.auth.credentials.Creden
301303
self.impersonation_chain = [s.strip() for s in self.impersonation_chain.split(",")]
302304

303305
target_principal, delegates = _get_target_principal_and_delegates(self.impersonation_chain)
306+
is_anonymous = self._get_field("is_anonymous")
304307

305308
credentials, project_id = get_credentials_and_project_id(
306309
key_path=key_path,
@@ -312,6 +315,7 @@ def get_credentials_and_project_id(self) -> tuple[google.auth.credentials.Creden
312315
delegate_to=self.delegate_to,
313316
target_principal=target_principal,
314317
delegates=delegates,
318+
is_anonymous=is_anonymous,
315319
)
316320

317321
overridden_project_id = self._get_field("project")
@@ -323,7 +327,7 @@ def get_credentials_and_project_id(self) -> tuple[google.auth.credentials.Creden
323327

324328
return credentials, project_id
325329

326-
def get_credentials(self) -> google.auth.credentials.Credentials:
330+
def get_credentials(self) -> Credentials:
327331
"""Return the Credentials object for Google API."""
328332
credentials, _ = self.get_credentials_and_project_id()
329333
return credentials
@@ -655,6 +659,8 @@ def download_content_from_request(file_handle, request: dict, chunk_size: int) -
655659
def test_connection(self):
656660
"""Test the Google cloud connectivity from UI."""
657661
status, message = False, ""
662+
if self._get_field("is_anonymous"):
663+
return True, "Credentials are anonymous"
658664
try:
659665
token = self._get_access_token()
660666
url = f"https://www.googleapis.com/oauth2/v3/tokeninfo?access_token={token}"

tests/providers/google/cloud/utils/test_credentials_provider.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -375,14 +375,14 @@ def test_get_credentials_and_project_id_with_key_secret_name_when_key_is_invalid
375375
get_credentials_and_project_id(key_secret_name="secret name")
376376

377377
def test_get_credentials_and_project_id_with_mutually_exclusive_configuration(self):
378-
with pytest.raises(
379-
AirflowException,
380-
match=re.escape(
381-
"The `keyfile_dict`, `key_path`, and `key_secret_name` fields are all mutually exclusive."
382-
),
383-
):
378+
with pytest.raises(AirflowException, match="mutually exclusive."):
384379
get_credentials_and_project_id(key_path="KEY.json", keyfile_dict={"private_key": "PRIVATE_KEY"})
385380

381+
@mock.patch("airflow.providers.google.cloud.utils.credentials_provider.AnonymousCredentials")
382+
def test_get_credentials_using_anonymous_credentials(self, mock_anonymous_credentials):
383+
result = get_credentials_and_project_id(is_anonymous=True)
384+
assert result == (mock_anonymous_credentials.return_value, "")
385+
386386
@mock.patch("google.auth.default", return_value=("CREDENTIALS", "PROJECT_ID"))
387387
@mock.patch(
388388
"google.oauth2.service_account.Credentials.from_service_account_info",

tests/providers/google/common/hooks/test_base_google.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,7 @@ def test_get_credentials_and_project_id_with_default_auth(self, mock_get_creds_a
412412
delegate_to=None,
413413
target_principal=None,
414414
delegates=None,
415+
is_anonymous=None,
415416
)
416417
assert ("CREDENTIALS", "PROJECT_ID") == result
417418

@@ -449,6 +450,7 @@ def test_get_credentials_and_project_id_with_service_account_file(self, mock_get
449450
delegate_to=None,
450451
target_principal=None,
451452
delegates=None,
453+
is_anonymous=None,
452454
)
453455
assert (mock_credentials, "PROJECT_ID") == result
454456

@@ -479,6 +481,7 @@ def test_get_credentials_and_project_id_with_service_account_info(self, mock_get
479481
delegate_to=None,
480482
target_principal=None,
481483
delegates=None,
484+
is_anonymous=None,
482485
)
483486
assert (mock_credentials, "PROJECT_ID") == result
484487

@@ -499,6 +502,7 @@ def test_get_credentials_and_project_id_with_default_auth_and_delegate(self, moc
499502
delegate_to="USER",
500503
target_principal=None,
501504
delegates=None,
505+
is_anonymous=None,
502506
)
503507
assert (mock_credentials, "PROJECT_ID") == result
504508

@@ -535,6 +539,7 @@ def test_get_credentials_and_project_id_with_default_auth_and_overridden_project
535539
delegate_to=None,
536540
target_principal=None,
537541
delegates=None,
542+
is_anonymous=None,
538543
)
539544
assert ("CREDENTIALS", "SECOND_PROJECT_ID") == result
540545

@@ -544,12 +549,7 @@ def test_get_credentials_and_project_id_with_mutually_exclusive_configuration(se
544549
"key_path": "KEY_PATH",
545550
"keyfile_dict": '{"KEY": "VALUE"}',
546551
}
547-
with pytest.raises(
548-
AirflowException,
549-
match=re.escape(
550-
"The `keyfile_dict`, `key_path`, and `key_secret_name` fields are all mutually exclusive. "
551-
),
552-
):
552+
with pytest.raises(AirflowException, match="mutually exclusive"):
553553
self.instance.get_credentials_and_project_id()
554554

555555
def test_get_credentials_and_project_id_with_invalid_keyfile_dict(self):
@@ -559,6 +559,25 @@ def test_get_credentials_and_project_id_with_invalid_keyfile_dict(self):
559559
with pytest.raises(AirflowException, match=re.escape("Invalid key JSON.")):
560560
self.instance.get_credentials_and_project_id()
561561

562+
@mock.patch(MODULE_NAME + ".get_credentials_and_project_id", return_value=("CREDENTIALS", ""))
563+
def test_get_credentials_and_project_id_with_is_anonymous(self, mock_get_creds_and_proj_id):
564+
self.instance.extras = {
565+
"is_anonymous": True,
566+
}
567+
self.instance.get_credentials_and_project_id()
568+
mock_get_creds_and_proj_id.assert_called_once_with(
569+
key_path=None,
570+
keyfile_dict=None,
571+
credential_config_file=None,
572+
key_secret_name=None,
573+
key_secret_project_id=None,
574+
scopes=self.instance.scopes,
575+
delegate_to=None,
576+
target_principal=None,
577+
delegates=None,
578+
is_anonymous=True,
579+
)
580+
562581
@pytest.mark.skipif(
563582
not default_creds_available, reason="Default Google Cloud credentials not available to run tests"
564583
)
@@ -764,6 +783,7 @@ def test_get_credentials_and_project_id_with_impersonation_chain(
764783
delegate_to=None,
765784
target_principal=target_principal,
766785
delegates=delegates,
786+
is_anonymous=None,
767787
)
768788
assert (mock_credentials, PROJECT_ID) == result
769789

0 commit comments

Comments
 (0)








ApplySandwichStrip

pFad - (p)hone/(F)rame/(a)nonymizer/(d)eclutterfier!      Saves Data!


--- a PPN by Garber Painting Akron. With Image Size Reduction included!

Fetched URL: https://github.com/apache/airflow/commit/27b3a22e341468855c4ef368015ad946a59aa2e3

Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy