Skip to content

Commit a8eb4c8

Browse files
feat: ADC can load an impersonated service account credentials. (#956)
* Make code changes in _default * Add unit tests. * Fix docstring. Co-authored-by: arithmetic1728 <[email protected]>
1 parent 87706fd commit a8eb4c8

5 files changed

+251
-36
lines changed

google/auth/_default.py

Lines changed: 115 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,13 @@
3535
_AUTHORIZED_USER_TYPE = "authorized_user"
3636
_SERVICE_ACCOUNT_TYPE = "service_account"
3737
_EXTERNAL_ACCOUNT_TYPE = "external_account"
38-
_VALID_TYPES = (_AUTHORIZED_USER_TYPE, _SERVICE_ACCOUNT_TYPE, _EXTERNAL_ACCOUNT_TYPE)
38+
_IMPERSONATED_SERVICE_ACCOUNT_TYPE = "impersonated_service_account"
39+
_VALID_TYPES = (
40+
_AUTHORIZED_USER_TYPE,
41+
_SERVICE_ACCOUNT_TYPE,
42+
_EXTERNAL_ACCOUNT_TYPE,
43+
_IMPERSONATED_SERVICE_ACCOUNT_TYPE,
44+
)
3945

4046
# Help message when no credentials can be found.
4147
_HELP_MESSAGE = """\
@@ -79,7 +85,8 @@ def load_credentials_from_file(
7985
"""Loads Google credentials from a file.
8086
8187
The credentials file must be a service account key, stored authorized
82-
user credentials or external account credentials.
88+
user credentials, external account credentials, or impersonated service
89+
account credentials.
8390
8491
Args:
8592
filename (str): The full path to the credentials file.
@@ -119,42 +126,25 @@ def load_credentials_from_file(
119126
"File {} is not a valid json file.".format(filename), caught_exc
120127
)
121128
six.raise_from(new_exc, caught_exc)
129+
return _load_credentials_from_info(
130+
filename, info, scopes, default_scopes, quota_project_id, request
131+
)
132+
122133

123-
# The type key should indicate that the file is either a service account
124-
# credentials file or an authorized user credentials file.
134+
def _load_credentials_from_info(
135+
filename, info, scopes, default_scopes, quota_project_id, request
136+
):
125137
credential_type = info.get("type")
126138

127139
if credential_type == _AUTHORIZED_USER_TYPE:
128-
from google.oauth2 import credentials
129-
130-
try:
131-
credentials = credentials.Credentials.from_authorized_user_info(
132-
info, scopes=scopes
133-
)
134-
except ValueError as caught_exc:
135-
msg = "Failed to load authorized user credentials from {}".format(filename)
136-
new_exc = exceptions.DefaultCredentialsError(msg, caught_exc)
137-
six.raise_from(new_exc, caught_exc)
138-
if quota_project_id:
139-
credentials = credentials.with_quota_project(quota_project_id)
140-
if not credentials.quota_project_id:
141-
_warn_about_problematic_credentials(credentials)
142-
return credentials, None
140+
credentials, project_id = _get_authorized_user_credentials(
141+
filename, info, scopes
142+
)
143143

144144
elif credential_type == _SERVICE_ACCOUNT_TYPE:
145-
from google.oauth2 import service_account
146-
147-
try:
148-
credentials = service_account.Credentials.from_service_account_info(
149-
info, scopes=scopes, default_scopes=default_scopes
150-
)
151-
except ValueError as caught_exc:
152-
msg = "Failed to load service account credentials from {}".format(filename)
153-
new_exc = exceptions.DefaultCredentialsError(msg, caught_exc)
154-
six.raise_from(new_exc, caught_exc)
155-
if quota_project_id:
156-
credentials = credentials.with_quota_project(quota_project_id)
157-
return credentials, info.get("project_id")
145+
credentials, project_id = _get_service_account_credentials(
146+
filename, info, scopes, default_scopes
147+
)
158148

159149
elif credential_type == _EXTERNAL_ACCOUNT_TYPE:
160150
credentials, project_id = _get_external_account_credentials(
@@ -164,17 +154,19 @@ def load_credentials_from_file(
164154
default_scopes=default_scopes,
165155
request=request,
166156
)
167-
if quota_project_id:
168-
credentials = credentials.with_quota_project(quota_project_id)
169-
return credentials, project_id
170-
157+
elif credential_type == _IMPERSONATED_SERVICE_ACCOUNT_TYPE:
158+
credentials, project_id = _get_impersonated_service_account_credentials(
159+
filename, info, scopes
160+
)
171161
else:
172162
raise exceptions.DefaultCredentialsError(
173163
"The file {file} does not have a valid type. "
174164
"Type is {type}, expected one of {valid_types}.".format(
175165
file=filename, type=credential_type, valid_types=_VALID_TYPES
176166
)
177167
)
168+
credentials = _apply_quota_project_id(credentials, quota_project_id)
169+
return credentials, project_id
178170

179171

180172
def _get_gcloud_sdk_credentials(quota_project_id=None):
@@ -371,6 +363,93 @@ def get_api_key_credentials(api_key_value):
371363
return api_key.Credentials(api_key_value)
372364

373365

366+
def _get_authorized_user_credentials(filename, info, scopes=None):
367+
from google.oauth2 import credentials
368+
369+
try:
370+
credentials = credentials.Credentials.from_authorized_user_info(
371+
info, scopes=scopes
372+
)
373+
except ValueError as caught_exc:
374+
msg = "Failed to load authorized user credentials from {}".format(filename)
375+
new_exc = exceptions.DefaultCredentialsError(msg, caught_exc)
376+
six.raise_from(new_exc, caught_exc)
377+
return credentials, None
378+
379+
380+
def _get_service_account_credentials(filename, info, scopes=None, default_scopes=None):
381+
from google.oauth2 import service_account
382+
383+
try:
384+
credentials = service_account.Credentials.from_service_account_info(
385+
info, scopes=scopes, default_scopes=default_scopes
386+
)
387+
except ValueError as caught_exc:
388+
msg = "Failed to load service account credentials from {}".format(filename)
389+
new_exc = exceptions.DefaultCredentialsError(msg, caught_exc)
390+
six.raise_from(new_exc, caught_exc)
391+
return credentials, info.get("project_id")
392+
393+
394+
def _get_impersonated_service_account_credentials(filename, info, scopes):
395+
from google.auth import impersonated_credentials
396+
397+
try:
398+
source_credentials_info = info.get("source_credentials")
399+
source_credentials_type = source_credentials_info.get("type")
400+
if source_credentials_type == _AUTHORIZED_USER_TYPE:
401+
source_credentials, _ = _get_authorized_user_credentials(
402+
filename, source_credentials_info
403+
)
404+
elif source_credentials_type == _SERVICE_ACCOUNT_TYPE:
405+
source_credentials, _ = _get_service_account_credentials(
406+
filename, source_credentials_info
407+
)
408+
else:
409+
raise ValueError(
410+
"source credential of type {} is not supported.".format(
411+
source_credentials_type
412+
)
413+
)
414+
impersonation_url = info.get("service_account_impersonation_url")
415+
start_index = impersonation_url.rfind("/")
416+
end_index = impersonation_url.find(":generateAccessToken")
417+
if start_index == -1 or end_index == -1 or start_index > end_index:
418+
raise ValueError(
419+
"Cannot extract target principal from {}".format(impersonation_url)
420+
)
421+
target_principal = impersonation_url[start_index + 1 : end_index]
422+
delegates = info.get("delegates")
423+
quota_project_id = info.get("quota_project_id")
424+
credentials = impersonated_credentials.Credentials(
425+
source_credentials,
426+
target_principal,
427+
scopes,
428+
delegates,
429+
quota_project_id=quota_project_id,
430+
)
431+
except ValueError as caught_exc:
432+
msg = "Failed to load impersonated service account credentials from {}".format(
433+
filename
434+
)
435+
new_exc = exceptions.DefaultCredentialsError(msg, caught_exc)
436+
six.raise_from(new_exc, caught_exc)
437+
return credentials, None
438+
439+
440+
def _apply_quota_project_id(credentials, quota_project_id):
441+
if quota_project_id:
442+
credentials = credentials.with_quota_project(quota_project_id)
443+
444+
from google.oauth2 import credentials as authorized_user_credentials
445+
446+
if isinstance(credentials, authorized_user_credentials.Credentials) and (
447+
not credentials.quota_project_id
448+
):
449+
_warn_about_problematic_credentials(credentials)
450+
return credentials
451+
452+
374453
def default(scopes=None, request=None, quota_project_id=None, default_scopes=None):
375454
"""Gets the default credentials for the current environment.
376455
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"delegates": [
3+
4+
],
5+
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/[email protected]:generateAccessToken",
6+
"source_credentials": {
7+
"client_id": "123",
8+
"client_secret": "secret",
9+
"refresh_token": "alabalaportocala",
10+
"type": "authorized_user"
11+
},
12+
"type": "impersonated_service_account"
13+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"delegates": [
3+
4+
],
5+
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/[email protected]:generateAccessToken",
6+
"source_credentials": {
7+
"type": "service_account",
8+
"project_id": "example-project",
9+
"private_key_id": "1",
10+
"private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj\n7wZgkdmM7oVK2OfgrSj/FCTkInKPqaCR0gD7K80q+mLBrN3PUkDrJQZpvRZIff3/\nxmVU1WeruQLFJjnFb2dqu0s/FY/2kWiJtBCakXvXEOb7zfbINuayL+MSsCGSdVYs\nSliS5qQpgyDap+8b5fpXZVJkq92hrcNtbkg7hCYUJczt8n9hcCTJCfUpApvaFQ18\npe+zpyl4+WzkP66I28hniMQyUlA1hBiskT7qiouq0m8IOodhv2fagSZKjOTTU2xk\nSBc//fy3ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQABAoIBAQDGGHzQxGKX+ANk\nnQi53v/c6632dJKYXVJC+PDAz4+bzU800Y+n/bOYsWf/kCp94XcG4Lgsdd0Gx+Zq\nHD9CI1IcqqBRR2AFscsmmX6YzPLTuEKBGMW8twaYy3utlFxElMwoUEsrSWRcCA1y\nnHSDzTt871c7nxCXHxuZ6Nm/XCL7Bg8uidRTSC1sQrQyKgTPhtQdYrPQ4WZ1A4J9\nIisyDYmZodSNZe5P+LTJ6M1SCgH8KH9ZGIxv3diMwzNNpk3kxJc9yCnja4mjiGE2\nYCNusSycU5IhZwVeCTlhQGcNeV/skfg64xkiJE34c2y2ttFbdwBTPixStGaF09nU\nZ422D40BAoGBAPvVyRRsC3BF+qZdaSMFwI1yiXY7vQw5+JZh01tD28NuYdRFzjcJ\nvzT2n8LFpj5ZfZFvSMLMVEFVMgQvWnN0O6xdXvGov6qlRUSGaH9u+TCPNnIldjMP\nB8+xTwFMqI7uQr54wBB+Poq7dVRP+0oHb0NYAwUBXoEuvYo3c/nDoRcZAoGBAOWl\naLHjMv4CJbArzT8sPfic/8waSiLV9Ixs3Re5YREUTtnLq7LoymqB57UXJB3BNz/2\neCueuW71avlWlRtE/wXASj5jx6y5mIrlV4nZbVuyYff0QlcG+fgb6pcJQuO9DxMI\naqFGrWP3zye+LK87a6iR76dS9vRU+bHZpSVvGMKJAoGAFGt3TIKeQtJJyqeUWNSk\nklORNdcOMymYMIlqG+JatXQD1rR6ThgqOt8sgRyJqFCVT++YFMOAqXOBBLnaObZZ\nCFbh1fJ66BlSjoXff0W+SuOx5HuJJAa5+WtFHrPajwxeuRcNa8jwxUsB7n41wADu\nUqWWSRedVBg4Ijbw3nWwYDECgYB0pLew4z4bVuvdt+HgnJA9n0EuYowVdadpTEJg\nsoBjNHV4msLzdNqbjrAqgz6M/n8Ztg8D2PNHMNDNJPVHjJwcR7duSTA6w2p/4k28\nbvvk/45Ta3XmzlxZcZSOct3O31Cw0i2XDVc018IY5be8qendDYM08icNo7vQYkRH\n504kQQKBgQDjx60zpz8ozvm1XAj0wVhi7GwXe+5lTxiLi9Fxq721WDxPMiHDW2XL\nYXfFVy/9/GIMvEiGYdmarK1NW+VhWl1DC5xhDg0kvMfxplt4tynoq1uTsQTY31Mx\nBeF5CT/JuNYk3bEBF0H/Q3VGO1/ggVS+YezdFbLWIRoMnLj6XCFEGg==\n-----END RSA PRIVATE KEY-----\n",
11+
"client_email": "[email protected]",
12+
"client_id": "1234",
13+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
14+
"token_uri": "https://accounts.google.com/o/oauth2/token"
15+
},
16+
"type": "impersonated_service_account"
17+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"delegates": [
3+
4+
],
5+
"quota_project_id": "quota_project",
6+
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/[email protected]:generateAccessToken",
7+
"source_credentials": {
8+
"client_id": "123",
9+
"client_secret": "secret",
10+
"refresh_token": "alabalaportocala",
11+
"type": "authorized_user"
12+
},
13+
"type": "impersonated_service_account"
14+
}

tests/test__default.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from google.auth import exceptions
2929
from google.auth import external_account
3030
from google.auth import identity_pool
31+
from google.auth import impersonated_credentials
3132
from google.oauth2 import service_account
3233
import google.oauth2.credentials
3334

@@ -128,6 +129,19 @@
128129
"workforce_pool_user_project": WORKFORCE_POOL_USER_PROJECT,
129130
}
130131

132+
IMPERSONATED_SERVICE_ACCOUNT_AUTHORIZED_USER_SOURCE_FILE = os.path.join(
133+
DATA_DIR, "impersonated_service_account_authorized_user_source.json"
134+
)
135+
136+
IMPERSONATED_SERVICE_ACCOUNT_WITH_QUOTA_PROJECT_FILE = os.path.join(
137+
DATA_DIR, "impersonated_service_account_with_quota_project.json"
138+
)
139+
140+
IMPERSONATED_SERVICE_ACCOUNT_SERVICE_ACCOUNT_SOURCE_FILE = os.path.join(
141+
DATA_DIR, "impersonated_service_account_service_account_source.json"
142+
)
143+
144+
131145
MOCK_CREDENTIALS = mock.Mock(spec=credentials.CredentialsWithQuotaProject)
132146
MOCK_CREDENTIALS.with_quota_project.return_value = MOCK_CREDENTIALS
133147

@@ -278,6 +292,84 @@ def test_load_credentials_from_file_service_account_bad_format(tmpdir):
278292
assert excinfo.match(r"missing fields")
279293

280294

295+
def test_load_credentials_from_file_impersonated_with_authorized_user_source():
296+
credentials, project_id = _default.load_credentials_from_file(
297+
IMPERSONATED_SERVICE_ACCOUNT_AUTHORIZED_USER_SOURCE_FILE
298+
)
299+
assert isinstance(credentials, impersonated_credentials.Credentials)
300+
assert isinstance(
301+
credentials._source_credentials, google.oauth2.credentials.Credentials
302+
)
303+
assert credentials.service_account_email == "[email protected]"
304+
assert credentials._delegates == ["[email protected]"]
305+
assert not credentials._quota_project_id
306+
assert not credentials._target_scopes
307+
assert project_id is None
308+
309+
310+
def test_load_credentials_from_file_impersonated_with_quota_project():
311+
credentials, _ = _default.load_credentials_from_file(
312+
IMPERSONATED_SERVICE_ACCOUNT_WITH_QUOTA_PROJECT_FILE
313+
)
314+
assert isinstance(credentials, impersonated_credentials.Credentials)
315+
assert credentials._quota_project_id == "quota_project"
316+
317+
318+
def test_load_credentials_from_file_impersonated_with_service_account_source():
319+
credentials, _ = _default.load_credentials_from_file(
320+
IMPERSONATED_SERVICE_ACCOUNT_SERVICE_ACCOUNT_SOURCE_FILE
321+
)
322+
assert isinstance(credentials, impersonated_credentials.Credentials)
323+
assert isinstance(credentials._source_credentials, service_account.Credentials)
324+
assert not credentials._quota_project_id
325+
326+
327+
def test_load_credentials_from_file_impersonated_passing_quota_project():
328+
credentials, _ = _default.load_credentials_from_file(
329+
IMPERSONATED_SERVICE_ACCOUNT_SERVICE_ACCOUNT_SOURCE_FILE,
330+
quota_project_id="new_quota_project",
331+
)
332+
assert credentials._quota_project_id == "new_quota_project"
333+
334+
335+
def test_load_credentials_from_file_impersonated_passing_scopes():
336+
credentials, _ = _default.load_credentials_from_file(
337+
IMPERSONATED_SERVICE_ACCOUNT_SERVICE_ACCOUNT_SOURCE_FILE,
338+
scopes=["scope1", "scope2"],
339+
)
340+
assert credentials._target_scopes == ["scope1", "scope2"]
341+
342+
343+
def test_load_credentials_from_file_impersonated_wrong_target_principal(tmpdir):
344+
345+
with open(IMPERSONATED_SERVICE_ACCOUNT_AUTHORIZED_USER_SOURCE_FILE) as fh:
346+
impersonated_credentials_info = json.load(fh)
347+
impersonated_credentials_info[
348+
"service_account_impersonation_url"
349+
] = "something_wrong"
350+
351+
jsonfile = tmpdir.join("invalid.json")
352+
jsonfile.write(json.dumps(impersonated_credentials_info))
353+
with pytest.raises(exceptions.DefaultCredentialsError) as excinfo:
354+
_default.load_credentials_from_file(str(jsonfile))
355+
356+
assert excinfo.match(r"Cannot extract target principal")
357+
358+
359+
def test_load_credentials_from_file_impersonated_wrong_source_type(tmpdir):
360+
361+
with open(IMPERSONATED_SERVICE_ACCOUNT_AUTHORIZED_USER_SOURCE_FILE) as fh:
362+
impersonated_credentials_info = json.load(fh)
363+
impersonated_credentials_info["source_credentials"]["type"] = "external_account"
364+
365+
jsonfile = tmpdir.join("invalid.json")
366+
jsonfile.write(json.dumps(impersonated_credentials_info))
367+
with pytest.raises(exceptions.DefaultCredentialsError) as excinfo:
368+
_default.load_credentials_from_file(str(jsonfile))
369+
370+
assert excinfo.match(r"source credential of type external_account is not supported")
371+
372+
281373
@EXTERNAL_ACCOUNT_GET_PROJECT_ID_PATCH
282374
def test_load_credentials_from_file_external_account_identity_pool(
283375
get_project_id, tmpdir

0 commit comments

Comments
 (0)