Skip to content

Commit b451e2d

Browse files
Gurov Ilyafrankyn
andauthored
feature: V4 Post policies (#87)
* feat: add POST policies building method * add comments, ignoring x-ignore fields and required fields validation * fix docs style, add virtual hosted style URLs * add bucket_bound_hostname support * cosmetic changes * add unit tests * Revert "add unit tests" This reverts commit f56440b. * add few lines from the old implementation for consistency * add some system tests * move system tests into separate class * fix credentials scope URL mistake * fix unit tests * fix algorithm name * add an example * add access token support * add credentials as an argument * rename method * add conformance tests into client unit tests * align conformance tests with test data * add an ability to set expiration as integer * update conformance tests to avoid problems with json spaces and timestamp Z-symbol violation * update implementation to avoid Z symbol isoformat violation and json whitespaces encoding * fix error with bounded hostnames * fix problem with bounded hostnames in implementation * fix conformance tests * fix problems: ascii encoding of signature and fields order * change asserts order * fix conformance tests * fix encoding issues * cosmetic changes and adding conformance tests * fix russion "C" letter in comment * add conformance tests data * cosmetic changes * cosmetic changes * add fields sorting * fix system tests Co-authored-by: Frank Natividad <frankyn@users.noreply.github.com>
1 parent a50cdd1 commit b451e2d

File tree

7 files changed

+897
-16
lines changed

7 files changed

+897
-16
lines changed

google/cloud/storage/_signing.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -531,9 +531,7 @@ def generate_signed_url_v4(
531531
expiration_seconds = get_expiration_seconds_v4(expiration)
532532

533533
if _request_timestamp is None:
534-
now = NOW()
535-
request_timestamp = now.strftime("%Y%m%dT%H%M%SZ")
536-
datestamp = now.date().strftime("%Y%m%d")
534+
request_timestamp, datestamp = get_v4_now_dtstamps()
537535
else:
538536
request_timestamp = _request_timestamp
539537
datestamp = _request_timestamp[:8]
@@ -629,6 +627,18 @@ def generate_signed_url_v4(
629627
)
630628

631629

630+
def get_v4_now_dtstamps():
631+
"""Get current timestamp and datestamp in V4 valid format.
632+
633+
:rtype: str, str
634+
:returns: Current timestamp, datestamp.
635+
"""
636+
now = NOW()
637+
timestamp = now.strftime("%Y%m%dT%H%M%SZ")
638+
datestamp = now.date().strftime("%Y%m%d")
639+
return timestamp, datestamp
640+
641+
632642
def _sign_message(message, access_token, service_account_email):
633643

634644
"""Signs a message.

google/cloud/storage/client.py

Lines changed: 180 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,28 @@
1414

1515
"""Client for interacting with the Google Cloud Storage API."""
1616

17-
import warnings
17+
import base64
18+
import binascii
19+
import datetime
1820
import functools
21+
import json
22+
import warnings
1923
import google.api_core.client_options
2024

2125
from google.auth.credentials import AnonymousCredentials
2226

2327
from google.api_core import page_iterator
24-
from google.cloud._helpers import _LocalStack
28+
from google.cloud._helpers import _LocalStack, _NOW
2529
from google.cloud.client import ClientWithProject
2630
from google.cloud.exceptions import NotFound
2731
from google.cloud.storage._helpers import _get_storage_host
2832
from google.cloud.storage._http import Connection
33+
from google.cloud.storage._signing import (
34+
get_expiration_seconds_v4,
35+
get_v4_now_dtstamps,
36+
ensure_signed_credentials,
37+
_sign_message,
38+
)
2939
from google.cloud.storage.batch import Batch
3040
from google.cloud.storage.bucket import Bucket
3141
from google.cloud.storage.blob import Blob
@@ -836,6 +846,174 @@ def get_hmac_key_metadata(
836846
metadata.reload(timeout=timeout) # raises NotFound for missing key
837847
return metadata
838848

849+
def generate_signed_post_policy_v4(
850+
self,
851+
bucket_name,
852+
blob_name,
853+
expiration,
854+
conditions=None,
855+
fields=None,
856+
credentials=None,
857+
virtual_hosted_style=False,
858+
bucket_bound_hostname=None,
859+
scheme=None,
860+
service_account_email=None,
861+
access_token=None,
862+
):
863+
"""Generate a V4 signed policy object.
864+
865+
.. note::
866+
867+
Assumes ``credentials`` implements the
868+
:class:`google.auth.credentials.Signing` interface. Also assumes
869+
``credentials`` has a ``service_account_email`` property which
870+
identifies the credentials.
871+
872+
Generated policy object allows user to upload objects with a POST request.
873+
874+
:type bucket_name: str
875+
:param bucket_name: Bucket name.
876+
877+
:type blob_name: str
878+
:param blob_name: Object name.
879+
880+
:type expiration: Union[Integer, datetime.datetime, datetime.timedelta]
881+
:param expiration: Policy expiration time.
882+
883+
:type conditions: list
884+
:param conditions: (Optional) List of POST policy conditions, which are
885+
used to restrict what is allowed in the request.
886+
887+
:type fields: dict
888+
:param fields: (Optional) Additional elements to include into request.
889+
890+
:type credentials: :class:`google.auth.credentials.Signing`
891+
:param credentials: (Optional) Credentials object with an associated private
892+
key to sign text.
893+
894+
:type virtual_hosted_style: bool
895+
:param virtual_hosted_style: (Optional) If True, construct the URL relative to the bucket
896+
virtual hostname, e.g., '<bucket-name>.storage.googleapis.com'.
897+
898+
:type bucket_bound_hostname: str
899+
:param bucket_bound_hostname:
900+
(Optional) If passed, construct the URL relative to the bucket-bound hostname.
901+
Value can be bare or with a scheme, e.g., 'example.com' or 'http://example.com'.
902+
See: https://cloud.google.com/storage/docs/request-endpoints#cname
903+
904+
:type scheme: str
905+
:param scheme:
906+
(Optional) If ``bucket_bound_hostname`` is passed as a bare hostname, use
907+
this value as a scheme. ``https`` will work only when using a CDN.
908+
Defaults to ``"http"``.
909+
910+
:type service_account_email: str
911+
:param service_account_email: (Optional) E-mail address of the service account.
912+
913+
:type access_token: str
914+
:param access_token: (Optional) Access token for a service account.
915+
916+
:rtype: dict
917+
:returns: Signed POST policy.
918+
919+
Example:
920+
Generate signed POST policy and upload a file.
921+
922+
>>> from google.cloud import storage
923+
>>> client = storage.Client()
924+
>>> policy = client.generate_signed_post_policy_v4(
925+
"bucket-name",
926+
"blob-name",
927+
expiration=datetime.datetime(2020, 3, 17),
928+
conditions=[
929+
["content-length-range", 0, 255]
930+
],
931+
fields=[
932+
"x-goog-meta-hello" => "world"
933+
],
934+
)
935+
>>> with open("bucket-name", "rb") as f:
936+
files = {"file": ("bucket-name", f)}
937+
requests.post(policy["url"], data=policy["fields"], files=files)
938+
"""
939+
credentials = self._credentials if credentials is None else credentials
940+
ensure_signed_credentials(credentials)
941+
942+
# prepare policy conditions and fields
943+
timestamp, datestamp = get_v4_now_dtstamps()
944+
945+
x_goog_credential = "{email}/{datestamp}/auto/storage/goog4_request".format(
946+
email=credentials.signer_email, datestamp=datestamp
947+
)
948+
required_conditions = [
949+
{"key": blob_name},
950+
{"x-goog-date": timestamp},
951+
{"x-goog-credential": x_goog_credential},
952+
{"x-goog-algorithm": "GOOG4-RSA-SHA256"},
953+
]
954+
955+
conditions = conditions or []
956+
policy_fields = {}
957+
for key, value in sorted((fields or {}).items()):
958+
if not key.startswith("x-ignore-"):
959+
policy_fields[key] = value
960+
conditions.append({key: value})
961+
962+
conditions += required_conditions
963+
964+
# calculate policy expiration time
965+
now = _NOW()
966+
if expiration is None:
967+
expiration = now + datetime.timedelta(hours=1)
968+
969+
policy_expires = now + datetime.timedelta(
970+
seconds=get_expiration_seconds_v4(expiration)
971+
)
972+
973+
# encode policy for signing
974+
policy = json.dumps(
975+
{"conditions": conditions, "expiration": policy_expires.isoformat() + "Z"},
976+
separators=(",", ":"),
977+
)
978+
str_to_sign = base64.b64encode(policy.encode("utf-8"))
979+
980+
# sign the policy and get its cryptographic signature
981+
if access_token and service_account_email:
982+
signature = _sign_message(str_to_sign, access_token, service_account_email)
983+
signature_bytes = base64.b64decode(signature)
984+
else:
985+
signature_bytes = credentials.sign_bytes(str_to_sign)
986+
987+
# get hexadecimal representation of the signature
988+
signature = binascii.hexlify(signature_bytes).decode("utf-8")
989+
990+
policy_fields.update(
991+
{
992+
"key": blob_name,
993+
"x-goog-algorithm": "GOOG4-RSA-SHA256",
994+
"x-goog-credential": x_goog_credential,
995+
"x-goog-date": timestamp,
996+
"x-goog-signature": signature,
997+
"policy": str_to_sign,
998+
}
999+
)
1000+
# designate URL
1001+
if virtual_hosted_style:
1002+
url = "https://{}.storage.googleapis.com/".format(bucket_name)
1003+
1004+
elif bucket_bound_hostname:
1005+
if ":" in bucket_bound_hostname: # URL includes scheme
1006+
url = bucket_bound_hostname
1007+
1008+
else: # scheme is given separately
1009+
url = "{scheme}://{host}/".format(
1010+
scheme=scheme, host=bucket_bound_hostname
1011+
)
1012+
else:
1013+
url = "https://storage.googleapis.com/{}/".format(bucket_name)
1014+
1015+
return {"url": url, "fields": policy_fields}
1016+
8391017

8401018
def _item_to_bucket(iterator, item):
8411019
"""Convert a JSON bucket to the native object.

tests/system/test_system.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1955,3 +1955,67 @@ def test_ubla_set_unset_preserves_acls(self):
19551955

19561956
self.assertEqual(bucket_acl_before, bucket_acl_after)
19571957
self.assertEqual(blob_acl_before, blob_acl_after)
1958+
1959+
1960+
class TestV4POSTPolicies(unittest.TestCase):
1961+
def setUp(self):
1962+
self.case_buckets_to_delete = []
1963+
1964+
def tearDown(self):
1965+
for bucket_name in self.case_buckets_to_delete:
1966+
bucket = Config.CLIENT.bucket(bucket_name)
1967+
retry_429_harder(bucket.delete)(force=True)
1968+
1969+
def test_get_signed_policy_v4(self):
1970+
bucket_name = "post_policy" + unique_resource_id("-")
1971+
self.assertRaises(exceptions.NotFound, Config.CLIENT.get_bucket, bucket_name)
1972+
retry_429_503(Config.CLIENT.create_bucket)(bucket_name)
1973+
self.case_buckets_to_delete.append(bucket_name)
1974+
1975+
blob_name = "post_policy_obj.txt"
1976+
with open(blob_name, "w") as f:
1977+
f.write("DEADBEEF")
1978+
1979+
policy = Config.CLIENT.generate_signed_post_policy_v4(
1980+
bucket_name,
1981+
blob_name,
1982+
conditions=[
1983+
{"bucket": bucket_name},
1984+
["starts-with", "$Content-Type", "text/pla"],
1985+
],
1986+
expiration=datetime.datetime.now() + datetime.timedelta(hours=1),
1987+
fields={"content-type": "text/plain"},
1988+
)
1989+
with open(blob_name, "r") as f:
1990+
files = {"file": (blob_name, f)}
1991+
response = requests.post(policy["url"], data=policy["fields"], files=files)
1992+
1993+
os.remove(blob_name)
1994+
self.assertEqual(response.status_code, 204)
1995+
1996+
def test_get_signed_policy_v4_invalid_field(self):
1997+
bucket_name = "post_policy" + unique_resource_id("-")
1998+
self.assertRaises(exceptions.NotFound, Config.CLIENT.get_bucket, bucket_name)
1999+
retry_429_503(Config.CLIENT.create_bucket)(bucket_name)
2000+
self.case_buckets_to_delete.append(bucket_name)
2001+
2002+
blob_name = "post_policy_obj.txt"
2003+
with open(blob_name, "w") as f:
2004+
f.write("DEADBEEF")
2005+
2006+
policy = Config.CLIENT.generate_signed_post_policy_v4(
2007+
bucket_name,
2008+
blob_name,
2009+
conditions=[
2010+
{"bucket": bucket_name},
2011+
["starts-with", "$Content-Type", "text/pla"],
2012+
],
2013+
expiration=datetime.datetime.now() + datetime.timedelta(hours=1),
2014+
fields={"x-goog-random": "invalid_field", "content-type": "text/plain"},
2015+
)
2016+
with open(blob_name, "r") as f:
2017+
files = {"file": (blob_name, f)}
2018+
response = requests.post(policy["url"], data=policy["fields"], files=files)
2019+
2020+
os.remove(blob_name)
2021+
self.assertEqual(response.status_code, 400)

tests/unit/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,14 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14+
15+
import io
16+
import json
17+
import os
18+
19+
20+
def _read_local_json(json_file):
21+
here = os.path.dirname(__file__)
22+
json_path = os.path.abspath(os.path.join(here, json_file))
23+
with io.open(json_path, "r", encoding="utf-8-sig") as fileobj:
24+
return json.load(fileobj)

tests/unit/test__signing.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,7 @@
1818
import binascii
1919
import calendar
2020
import datetime
21-
import io
2221
import json
23-
import os
2422
import time
2523
import unittest
2624

@@ -29,12 +27,7 @@
2927
import six
3028
from six.moves import urllib_parse
3129

32-
33-
def _read_local_json(json_file):
34-
here = os.path.dirname(__file__)
35-
json_path = os.path.abspath(os.path.join(here, json_file))
36-
with io.open(json_path, "r", encoding="utf-8-sig") as fileobj:
37-
return json.load(fileobj)
30+
from . import _read_local_json
3831

3932

4033
_SERVICE_ACCOUNT_JSON = _read_local_json("url_signer_v4_test_account.json")
@@ -762,6 +755,22 @@ def test_bytes(self):
762755
self.assertEqual(encoded_param, "bytes")
763756

764757

758+
class TestV4Stamps(unittest.TestCase):
759+
def test_get_v4_now_dtstamps(self):
760+
import datetime
761+
from google.cloud.storage._signing import get_v4_now_dtstamps
762+
763+
with mock.patch(
764+
"google.cloud.storage._signing.NOW",
765+
return_value=datetime.datetime(2020, 3, 12, 13, 14, 15),
766+
) as now_mock:
767+
timestamp, datestamp = get_v4_now_dtstamps()
768+
now_mock.assert_called_once()
769+
770+
self.assertEqual(timestamp, "20200312T131415Z")
771+
self.assertEqual(datestamp, "20200312")
772+
773+
765774
_DUMMY_SERVICE_ACCOUNT = None
766775

767776

0 commit comments

Comments
 (0)