Skip to content

Commit ad280bf

Browse files
feat(storage): add support for signing URLs using token (#9889)
* feat(storage): add support for signing URLs using token * feat(bigquery): add system tests for the feature * feat(storage): add iam dependency in nox file and cosmetic changes
1 parent afc3eb2 commit ad280bf

File tree

6 files changed

+207
-9
lines changed

6 files changed

+207
-9
lines changed

google/cloud/storage/_signing.py

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,14 @@
1919
import datetime
2020
import hashlib
2121
import re
22+
import json
2223

2324
import six
2425

2526
import google.auth.credentials
27+
28+
from google.auth import exceptions
29+
from google.auth.transport import requests
2630
from google.cloud import _helpers
2731

2832

@@ -265,6 +269,8 @@ def generate_signed_url_v2(
265269
generation=None,
266270
headers=None,
267271
query_parameters=None,
272+
service_account_email=None,
273+
access_token=None,
268274
):
269275
"""Generate a V2 signed URL to provide query-string auth'n to a resource.
270276
@@ -340,6 +346,12 @@ def generate_signed_url_v2(
340346
Requests using the signed URL *must* pass the specified header
341347
(name and value) with each request for the URL.
342348
349+
:type service_account_email: str
350+
:param service_account_email: (Optional) E-mail address of the service account.
351+
352+
:type access_token: str
353+
:param access_token: (Optional) Access token for a service account.
354+
343355
:type query_parameters: dict
344356
:param query_parameters:
345357
(Optional) Additional query paramtersto be included as part of the
@@ -370,9 +382,17 @@ def generate_signed_url_v2(
370382
string_to_sign = "\n".join(elements_to_sign)
371383

372384
# Set the right query parameters.
373-
signed_query_params = get_signed_query_params_v2(
374-
credentials, expiration_stamp, string_to_sign
375-
)
385+
if access_token and service_account_email:
386+
signature = _sign_message(string_to_sign, access_token, service_account_email)
387+
signed_query_params = {
388+
"GoogleAccessId": service_account_email,
389+
"Expires": str(expiration),
390+
"Signature": signature,
391+
}
392+
else:
393+
signed_query_params = get_signed_query_params_v2(
394+
credentials, expiration_stamp, string_to_sign
395+
)
376396

377397
if response_type is not None:
378398
signed_query_params["response-content-type"] = response_type
@@ -409,6 +429,8 @@ def generate_signed_url_v4(
409429
generation=None,
410430
headers=None,
411431
query_parameters=None,
432+
service_account_email=None,
433+
access_token=None,
412434
_request_timestamp=None, # for testing only
413435
):
414436
"""Generate a V4 signed URL to provide query-string auth'n to a resource.
@@ -492,6 +514,12 @@ def generate_signed_url_v4(
492514
signed URLs. See:
493515
https://cloud.google.com/storage/docs/xml-api/reference-headers#query
494516
517+
:type service_account_email: str
518+
:param service_account_email: (Optional) E-mail address of the service account.
519+
520+
:type access_token: str
521+
:param access_token: (Optional) Access token for a service account.
522+
495523
:raises: :exc:`TypeError` when expiration is not a valid type.
496524
:raises: :exc:`AttributeError` if credentials is not an instance
497525
of :class:`google.auth.credentials.Signing`.
@@ -583,9 +611,58 @@ def generate_signed_url_v4(
583611
]
584612
string_to_sign = "\n".join(string_elements)
585613

586-
signature_bytes = credentials.sign_bytes(string_to_sign.encode("ascii"))
587-
signature = binascii.hexlify(signature_bytes).decode("ascii")
614+
if access_token and service_account_email:
615+
signature = _sign_message(string_to_sign, access_token, service_account_email)
616+
signature_bytes = base64.b64decode(signature)
617+
signature = binascii.hexlify(signature_bytes).decode("ascii")
618+
else:
619+
signature_bytes = credentials.sign_bytes(string_to_sign.encode("ascii"))
620+
signature = binascii.hexlify(signature_bytes).decode("ascii")
588621

589622
return "{}{}?{}&X-Goog-Signature={}".format(
590623
api_access_endpoint, resource, canonical_query_string, signature
591624
)
625+
626+
627+
def _sign_message(message, access_token, service_account_email):
628+
629+
"""Signs a message.
630+
631+
:type message: str
632+
:param message: The message to be signed.
633+
634+
:type access_token: str
635+
:param access_token: Access token for a service account.
636+
637+
638+
:type service_account_email: str
639+
:param service_account_email: E-mail address of the service account.
640+
641+
:raises: :exc:`TransportError` if an `access_token` is unauthorized.
642+
643+
:rtype: str
644+
:returns: The signature of the message.
645+
646+
"""
647+
message = _helpers._to_bytes(message)
648+
649+
method = "POST"
650+
url = "https://iam.googleapis.com/v1/projects/-/serviceAccounts/{}:signBlob?alt=json".format(
651+
service_account_email
652+
)
653+
headers = {
654+
"Authorization": "Bearer " + access_token,
655+
"Content-type": "application/json",
656+
}
657+
body = json.dumps({"bytesToSign": base64.b64encode(message).decode("utf-8")})
658+
659+
request = requests.Request()
660+
response = request(url=url, method=method, body=body, headers=headers)
661+
662+
if response.status != six.moves.http_client.OK:
663+
raise exceptions.TransportError(
664+
"Error calling the IAM signBytes API: {}".format(response.data)
665+
)
666+
667+
data = json.loads(response.data.decode("utf-8"))
668+
return data["signature"]

google/cloud/storage/blob.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,8 @@ def generate_signed_url(
358358
client=None,
359359
credentials=None,
360360
version=None,
361+
service_account_email=None,
362+
access_token=None,
361363
):
362364
"""Generates a signed URL for this blob.
363365
@@ -445,6 +447,12 @@ def generate_signed_url(
445447
:param version: (Optional) The version of signed credential to create.
446448
Must be one of 'v2' | 'v4'.
447449
450+
:type service_account_email: str
451+
:param service_account_email: (Optional) E-mail address of the service account.
452+
453+
:type access_token: str
454+
:param access_token: (Optional) Access token for a service account.
455+
448456
:raises: :exc:`ValueError` when version is invalid.
449457
:raises: :exc:`TypeError` when expiration is not a valid type.
450458
:raises: :exc:`AttributeError` if credentials is not an instance
@@ -497,6 +505,8 @@ def generate_signed_url(
497505
generation=generation,
498506
headers=headers,
499507
query_parameters=query_parameters,
508+
service_account_email=service_account_email,
509+
access_token=access_token,
500510
)
501511

502512
def exists(self, client=None):

noxfile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ def system(session):
112112
session.install("mock", "pytest")
113113
for local_dep in LOCAL_DEPS:
114114
session.install("-e", local_dep)
115-
systest_deps = ["../test_utils/", "../pubsub", "../kms"]
115+
systest_deps = ["../test_utils/", "../pubsub", "../kms", "../iam"]
116116
for systest_dep in systest_deps:
117117
session.install("-e", systest_dep)
118118
session.install("-e", ".")

tests/system.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,13 @@
2727
import six
2828

2929
from google.cloud import exceptions
30+
from google.cloud import iam_credentials_v1
3031
from google.cloud import storage
3132
from google.cloud.storage._helpers import _base64_md5hash
3233
from google.cloud.storage.bucket import LifecycleRuleDelete
3334
from google.cloud.storage.bucket import LifecycleRuleSetStorageClass
3435
from google.cloud import kms
3536
import google.oauth2
36-
3737
from test_utils.retry import RetryErrors
3838
from test_utils.system import unique_resource_id
3939
from test_utils.vpcsc_config import vpcsc_config
@@ -109,7 +109,6 @@ def tearDown(self):
109109

110110
def test_get_service_account_email(self):
111111
domain = "gs-project-accounts.iam.gserviceaccount.com"
112-
113112
email = Config.CLIENT.get_service_account_email()
114113

115114
new_style = re.compile(r"service-(?P<projnum>[^@]+)@" + domain)
@@ -962,6 +961,8 @@ def _create_signed_read_url_helper(
962961
payload=None,
963962
expiration=None,
964963
encryption_key=None,
964+
service_account_email=None,
965+
access_token=None,
965966
):
966967
expiration = self._morph_expiration(version, expiration)
967968

@@ -972,7 +973,12 @@ def _create_signed_read_url_helper(
972973
blob = self.blob
973974

974975
signed_url = blob.generate_signed_url(
975-
expiration=expiration, method=method, client=Config.CLIENT, version=version
976+
expiration=expiration,
977+
method=method,
978+
client=Config.CLIENT,
979+
version=version,
980+
service_account_email=None,
981+
access_token=None,
976982
)
977983

978984
headers = {}
@@ -1045,6 +1051,29 @@ def test_create_signed_read_url_v4_w_csek(self):
10451051
version="v4",
10461052
)
10471053

1054+
def test_create_signed_read_url_v2_w_access_token(self):
1055+
client = iam_credentials_v1.IAMCredentialsClient()
1056+
service_account_email = Config.CLIENT._credentials.service_account_email
1057+
name = client.service_account_path("-", service_account_email)
1058+
scope = ["https://www.googleapis.com/auth/devstorage.read_write"]
1059+
response = client.generate_access_token(name, scope)
1060+
self._create_signed_read_url_helper(
1061+
service_account_email=service_account_email,
1062+
access_token=response.access_token,
1063+
)
1064+
1065+
def test_create_signed_read_url_v4_w_access_token(self):
1066+
client = iam_credentials_v1.IAMCredentialsClient()
1067+
service_account_email = Config.CLIENT._credentials.service_account_email
1068+
name = client.service_account_path("-", service_account_email)
1069+
scope = ["https://www.googleapis.com/auth/devstorage.read_write"]
1070+
response = client.generate_access_token(name, scope)
1071+
self._create_signed_read_url_helper(
1072+
version="v4",
1073+
service_account_email=service_account_email,
1074+
access_token=response.access_token,
1075+
)
1076+
10481077
def _create_signed_delete_url_helper(self, version="v2", expiration=None):
10491078
expiration = self._morph_expiration(version, expiration)
10501079

tests/unit/test__signing.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,8 @@ def _generate_helper(
390390
generation=generation,
391391
headers=headers,
392392
query_parameters=query_parameters,
393+
service_account_email=None,
394+
access_token=None,
393395
)
394396

395397
# Check the mock was called.
@@ -504,6 +506,22 @@ def test_with_google_credentials(self):
504506
with self.assertRaises(AttributeError):
505507
self._call_fut(credentials, resource=resource, expiration=expiration)
506508

509+
def test_with_access_token(self):
510+
resource = "/name/path"
511+
credentials = _make_credentials()
512+
expiration = int(time.time() + 5)
513+
email = mock.sentinel.service_account_email
514+
with mock.patch(
515+
"google.cloud.storage._signing._sign_message", return_value=b"DEADBEEF"
516+
):
517+
self._call_fut(
518+
credentials,
519+
resource=resource,
520+
expiration=expiration,
521+
service_account_email=email,
522+
access_token="token",
523+
)
524+
507525

508526
class Test_generate_signed_url_v4(unittest.TestCase):
509527
DEFAULT_EXPIRATION = 1000
@@ -638,6 +656,51 @@ def test_w_custom_query_parameters_w_string_value(self):
638656
def test_w_custom_query_parameters_w_none_value(self):
639657
self._generate_helper(query_parameters={"qux": None})
640658

659+
def test_with_access_token(self):
660+
resource = "/name/path"
661+
signer_email = "service@example.com"
662+
credentials = _make_credentials(signer_email=signer_email)
663+
with mock.patch(
664+
"google.cloud.storage._signing._sign_message", return_value=b"DEADBEEF"
665+
):
666+
self._call_fut(
667+
credentials,
668+
resource=resource,
669+
expiration=datetime.timedelta(days=5),
670+
service_account_email=signer_email,
671+
access_token="token",
672+
)
673+
674+
675+
class Test_sign_message(unittest.TestCase):
676+
@staticmethod
677+
def _call_fut(*args, **kwargs):
678+
from google.cloud.storage._signing import _sign_message
679+
680+
return _sign_message(*args, **kwargs)
681+
682+
def test_sign_bytes(self):
683+
signature = "DEADBEEF"
684+
data = {"signature": signature}
685+
request = make_request(200, data)
686+
with mock.patch("google.auth.transport.requests.Request", return_value=request):
687+
returned_signature = self._call_fut(
688+
"123", service_account_email="service@example.com", access_token="token"
689+
)
690+
assert returned_signature == signature
691+
692+
def test_sign_bytes_failure(self):
693+
from google.auth import exceptions
694+
695+
request = make_request(401)
696+
with mock.patch("google.auth.transport.requests.Request", return_value=request):
697+
with pytest.raises(exceptions.TransportError):
698+
self._call_fut(
699+
"123",
700+
service_account_email="service@example.com",
701+
access_token="token",
702+
)
703+
641704

642705
_DUMMY_SERVICE_ACCOUNT = None
643706

@@ -697,3 +760,16 @@ def _make_credentials(signer_email=None):
697760
return credentials
698761
else:
699762
return mock.Mock(spec=google.auth.credentials.Credentials)
763+
764+
765+
def make_request(status, data=None):
766+
from google.auth import transport
767+
768+
response = mock.create_autospec(transport.Response, instance=True)
769+
response.status = status
770+
if data is not None:
771+
response.data = json.dumps(data).encode("utf-8")
772+
773+
request = mock.create_autospec(transport.Request)
774+
request.return_value = response
775+
return request

tests/unit/test_blob.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,8 @@ def _generate_signed_url_helper(
391391
credentials=None,
392392
expiration=None,
393393
encryption_key=None,
394+
access_token=None,
395+
service_account_email=None,
394396
):
395397
from six.moves.urllib import parse
396398
from google.cloud._helpers import UTC
@@ -432,6 +434,8 @@ def _generate_signed_url_helper(
432434
headers=headers,
433435
query_parameters=query_parameters,
434436
version=version,
437+
access_token=access_token,
438+
service_account_email=service_account_email,
435439
)
436440

437441
self.assertEqual(signed_uri, signer.return_value)
@@ -464,6 +468,8 @@ def _generate_signed_url_helper(
464468
"generation": generation,
465469
"headers": expected_headers,
466470
"query_parameters": query_parameters,
471+
"access_token": access_token,
472+
"service_account_email": service_account_email,
467473
}
468474
signer.assert_called_once_with(expected_creds, **expected_kwargs)
469475

0 commit comments

Comments
 (0)