Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: V4 Post policies #87

Merged
merged 43 commits into from
Apr 1, 2020
Merged
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
447310f
feat: add POST policies building method
Mar 11, 2020
fa09133
add comments, ignoring x-ignore fields and required fields validation
Mar 11, 2020
6818fb6
fix docs style, add virtual hosted style URLs
Mar 11, 2020
c216a59
add bucket_bound_hostname support
Mar 11, 2020
b01c801
cosmetic changes
Mar 12, 2020
f56440b
add unit tests
Mar 12, 2020
dc7028d
Revert "add unit tests"
Mar 12, 2020
0a442b4
add few lines from the old implementation for consistency
Mar 12, 2020
4982ad7
add some system tests
Mar 13, 2020
333e212
move system tests into separate class
Mar 16, 2020
173c3f6
fix credentials scope URL mistake
Mar 16, 2020
1c80907
fix unit tests
Mar 16, 2020
3f15f2e
fix algorithm name
Mar 16, 2020
9acc3d7
add an example
Mar 17, 2020
b60c45e
add access token support
Mar 20, 2020
77ec997
Merge branch 'master' into v4_post_policies
frankyn Mar 23, 2020
b6c4106
add credentials as an argument
Mar 23, 2020
32b2f73
Merge branch 'v4_post_policies' of https://github.com/q-logic/python-…
Mar 23, 2020
51469a8
rename method
Mar 24, 2020
ade9985
add conformance tests into client unit tests
Mar 25, 2020
f62c48e
align conformance tests with test data
Mar 26, 2020
0572627
add an ability to set expiration as integer
Mar 26, 2020
edf84da
Merge branch 'v4_post_policies' into post_policy_conformance_tests
Mar 26, 2020
a630984
update conformance tests to avoid problems with json spaces and times…
Mar 26, 2020
d0028c9
update implementation to avoid Z symbol isoformat violation and json …
Mar 26, 2020
1de8570
Merge branch 'v4_post_policies' into post_policy_conformance_tests
Mar 26, 2020
c8bce87
fix error with bounded hostnames
Mar 26, 2020
7ecf43d
fix problem with bounded hostnames in implementation
Mar 26, 2020
353e4e2
Merge branch 'v4_post_policies' into post_policy_conformance_tests
Mar 26, 2020
1c87755
fix conformance tests
Mar 27, 2020
6aa3ea5
fix problems: ascii encoding of signature and fields order
Mar 27, 2020
0b14b3b
Merge branch 'v4_post_policies' into post_policy_conformance_tests
Mar 27, 2020
2597d29
change asserts order
Mar 27, 2020
7c4bd6c
fix conformance tests
Mar 30, 2020
55a6d42
fix encoding issues
Mar 30, 2020
2fd0025
merge unit test
Mar 30, 2020
8506d83
cosmetic changes and adding conformance tests
Mar 31, 2020
fd31846
fix russion "C" letter in comment
Mar 31, 2020
9048cc5
add conformance tests data
Mar 31, 2020
81759b2
cosmetic changes
Mar 31, 2020
8fcb8bf
cosmetic changes
Mar 31, 2020
7b1406e
add fields sorting
Apr 1, 2020
16daedd
fix system tests
Apr 1, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions google/cloud/storage/_signing.py
Original file line number Diff line number Diff line change
Expand Up @@ -531,9 +531,7 @@ def generate_signed_url_v4(
expiration_seconds = get_expiration_seconds_v4(expiration)

if _request_timestamp is None:
now = NOW()
request_timestamp = now.strftime("%Y%m%dT%H%M%SZ")
datestamp = now.date().strftime("%Y%m%d")
IlyaFaer marked this conversation as resolved.
Show resolved Hide resolved
request_timestamp, datestamp = get_v4_now_dtstamps()
else:
request_timestamp = _request_timestamp
datestamp = _request_timestamp[:8]
Expand Down Expand Up @@ -629,6 +627,18 @@ def generate_signed_url_v4(
)


def get_v4_now_dtstamps():
"""Get current timestamp and datestamp in V4 valid format.

:rtype: str, str
:returns: Current timestamp, datestamp.
"""
now = NOW()
timestamp = now.strftime("%Y%m%dT%H%M%SZ")
datestamp = now.date().strftime("%Y%m%d")
return timestamp, datestamp


def _sign_message(message, access_token, service_account_email):

"""Signs a message.
Expand Down
182 changes: 180 additions & 2 deletions google/cloud/storage/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,28 @@

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

import warnings
import base64
import binascii
import datetime
import functools
import json
import warnings
import google.api_core.client_options

from google.auth.credentials import AnonymousCredentials

from google.api_core import page_iterator
from google.cloud._helpers import _LocalStack
from google.cloud._helpers import _LocalStack, _NOW
from google.cloud.client import ClientWithProject
from google.cloud.exceptions import NotFound
from google.cloud.storage._helpers import _get_storage_host
from google.cloud.storage._http import Connection
from google.cloud.storage._signing import (
get_expiration_seconds_v4,
get_v4_now_dtstamps,
ensure_signed_credentials,
_sign_message,
)
from google.cloud.storage.batch import Batch
from google.cloud.storage.bucket import Bucket
from google.cloud.storage.blob import Blob
Expand Down Expand Up @@ -836,6 +846,174 @@ def get_hmac_key_metadata(
metadata.reload(timeout=timeout) # raises NotFound for missing key
return metadata

def generate_signed_post_policy_v4(
self,
bucket_name,
blob_name,
expiration,
IlyaFaer marked this conversation as resolved.
Show resolved Hide resolved
conditions=None,
fields=None,
credentials=None,
virtual_hosted_style=False,
bucket_bound_hostname=None,
scheme=None,
IlyaFaer marked this conversation as resolved.
Show resolved Hide resolved
service_account_email=None,
access_token=None,
):
"""Generate a V4 signed policy object.
IlyaFaer marked this conversation as resolved.
Show resolved Hide resolved

.. note::

Assumes ``credentials`` implements the
:class:`google.auth.credentials.Signing` interface. Also assumes
``credentials`` has a ``service_account_email`` property which
identifies the credentials.

Generated policy object allows user to upload objects with a POST request.

:type bucket_name: str
:param bucket_name: Bucket name.

:type blob_name: str
:param blob_name: Object name.

:type expiration: Union[Integer, datetime.datetime, datetime.timedelta]
:param expiration: Policy expiration time.

:type conditions: list
:param conditions: (Optional) List of POST policy conditions, which are
used to restrict what is allowed in the request.

:type fields: dict
:param fields: (Optional) Additional elements to include into request.

:type credentials: :class:`google.auth.credentials.Signing`
:param credentials: (Optional) Credentials object with an associated private
key to sign text.

:type virtual_hosted_style: bool
:param virtual_hosted_style: (Optional) If True, construct the URL relative to the bucket
virtual hostname, e.g., '<bucket-name>.storage.googleapis.com'.

:type bucket_bound_hostname: str
:param bucket_bound_hostname:
(Optional) If passed, construct the URL relative to the bucket-bound hostname.
Value can be bare or with a scheme, e.g., 'example.com' or 'http://example.com'.
See: https://cloud.google.com/storage/docs/request-endpoints#cname

:type scheme: str
:param scheme:
(Optional) If ``bucket_bound_hostname`` is passed as a bare hostname, use
this value as a scheme. ``https`` will work only when using a CDN.
Defaults to ``"http"``.

:type service_account_email: str
:param service_account_email: (Optional) E-mail address of the service account.

:type access_token: str
:param access_token: (Optional) Access token for a service account.

:rtype: dict
:returns: Signed POST policy.

Example:
Generate signed POST policy and upload a file.

>>> from google.cloud import storage
>>> client = storage.Client()
>>> policy = client.generate_signed_post_policy_v4(
"bucket-name",
"blob-name",
expiration=datetime.datetime(2020, 3, 17),
conditions=[
["content-length-range", 0, 255]
],
fields=[
"x-goog-meta-hello" => "world"
],
)
>>> with open("bucket-name", "rb") as f:
files = {"file": ("bucket-name", f)}
requests.post(policy["url"], data=policy["fields"], files=files)
IlyaFaer marked this conversation as resolved.
Show resolved Hide resolved
"""
credentials = self._credentials if credentials is None else credentials
ensure_signed_credentials(credentials)

# prepare policy conditions and fields
timestamp, datestamp = get_v4_now_dtstamps()

x_goog_credential = "{email}/{datestamp}/auto/storage/goog4_request".format(
email=credentials.signer_email, datestamp=datestamp
)
required_conditions = [
{"key": blob_name},
{"x-goog-date": timestamp},
{"x-goog-credential": x_goog_credential},
{"x-goog-algorithm": "GOOG4-RSA-SHA256"},
]

conditions = conditions or []
policy_fields = {}
for key, value in (fields or {}).items():
if not key.startswith("x-ignore-"):
policy_fields[key] = value
conditions.append({key: value})

conditions += required_conditions

# calculate policy expiration time
now = _NOW()
if expiration is None:
expiration = now + datetime.timedelta(hours=1)

policy_expires = now + datetime.timedelta(
seconds=get_expiration_seconds_v4(expiration)
)

# encode policy for signing
policy = json.dumps(
{"conditions": conditions, "expiration": policy_expires.isoformat() + "Z"},
separators=(",", ":"),
)
str_to_sign = base64.b64encode(policy.encode("utf-8"))

# sign the policy and get its cryptographic signature
if access_token and service_account_email:
signature = _sign_message(str_to_sign, access_token, service_account_email)
signature_bytes = base64.b64decode(signature)
else:
signature_bytes = credentials.sign_bytes(str_to_sign)
IlyaFaer marked this conversation as resolved.
Show resolved Hide resolved

# get hexadecimal representation of the signature
signature = binascii.hexlify(signature_bytes).decode("utf-8")

policy_fields.update(
{
"key": blob_name,
"x-goog-algorithm": "GOOG4-RSA-SHA256",
"x-goog-credential": x_goog_credential,
"x-goog-date": timestamp,
"x-goog-signature": signature,
"policy": str_to_sign,
}
)
# designate URL
if virtual_hosted_style:
url = "https://{}.storage.googleapis.com/".format(bucket_name)

elif bucket_bound_hostname:
if ":" in bucket_bound_hostname: # URL includes scheme
url = bucket_bound_hostname

else: # scheme is given separately
url = "{scheme}://{host}/".format(
scheme=scheme, host=bucket_bound_hostname
)
else:
url = "https://storage.googleapis.com/{}/".format(bucket_name)

return {"url": url, "fields": policy_fields}


def _item_to_bucket(iterator, item):
"""Convert a JSON bucket to the native object.
Expand Down
63 changes: 63 additions & 0 deletions tests/system/test_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -1955,3 +1955,66 @@ def test_ubla_set_unset_preserves_acls(self):

self.assertEqual(bucket_acl_before, bucket_acl_after)
self.assertEqual(blob_acl_before, blob_acl_after)


class TestV4POSTPolicies(unittest.TestCase):
def setUp(self):
self.case_buckets_to_delete = []

def tearDown(self):
for bucket_name in self.case_buckets_to_delete:
bucket = Config.CLIENT.bucket(bucket_name)
retry_429_harder(bucket.delete)(force=True)

def test_get_signed_policy_v4(self):
bucket_name = "post_policy" + unique_resource_id("-")
self.assertRaises(exceptions.NotFound, Config.CLIENT.get_bucket, bucket_name)
retry_429_503(Config.CLIENT.create_bucket)(bucket_name)
self.case_buckets_to_delete.append(bucket_name)

blob_name = "post_policy_obj.txt"
with open(blob_name, "wb") as f:
f.write(b"DEADBEEF")

policy = Config.CLIENT.generate_signed_post_policy_v4(
bucket_name,
blob_name,
conditions=[
["starts-with", "$Content-Type", "text/plain"],
{"bucket": bucket_name},
],
expiration=datetime.datetime.now() + datetime.timedelta(hours=1),
)
with open(blob_name, "rb") as f:
files = {"file": (blob_name, f)}
response = requests.post(policy["url"], data=policy["fields"], files=files)

os.remove(blob_name)
self.assertEqual(response.status_code, 200)

def test_get_signed_policy_v4_invalid_field(self):
bucket_name = "post_policy" + unique_resource_id("-")
self.assertRaises(exceptions.NotFound, Config.CLIENT.get_bucket, bucket_name)
retry_429_503(Config.CLIENT.create_bucket)(bucket_name)
self.case_buckets_to_delete.append(bucket_name)

blob_name = "post_policy_obj.txt"
with open(blob_name, "wb") as f:
f.write(b"DEADBEEF")

policy = Config.CLIENT.generate_signed_post_policy_v4(
bucket_name,
blob_name,
conditions=[
["starts-with", "$Content-Type", "text/plain"],
{"bucket": bucket_name},
],
expiration=datetime.datetime.now() + datetime.timedelta(hours=1),
fields={"x-goog-random": "invalid_field"},
)
with open(blob_name, "rb") as f:
files = {"file": (blob_name, f)}
response = requests.post(policy["url"], data=policy["fields"], files=files)

os.remove(blob_name)
self.assertEqual(response.status_code, 400)
11 changes: 11 additions & 0 deletions tests/unit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,14 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import io
import json
import os


def _read_local_json(json_file):
here = os.path.dirname(__file__)
json_path = os.path.abspath(os.path.join(here, json_file))
with io.open(json_path, "r", encoding="utf-8-sig") as fileobj:
return json.load(fileobj)
25 changes: 17 additions & 8 deletions tests/unit/test__signing.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,7 @@
import binascii
import calendar
import datetime
import io
import json
import os
import time
import unittest

Expand All @@ -29,12 +27,7 @@
import six
from six.moves import urllib_parse


def _read_local_json(json_file):
here = os.path.dirname(__file__)
json_path = os.path.abspath(os.path.join(here, json_file))
with io.open(json_path, "r", encoding="utf-8-sig") as fileobj:
return json.load(fileobj)
from . import _read_local_json


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


class TestV4Stamps(unittest.TestCase):
def test_get_v4_now_dtstamps(self):
import datetime
from google.cloud.storage._signing import get_v4_now_dtstamps

with mock.patch(
"google.cloud.storage._signing.NOW",
return_value=datetime.datetime(2020, 3, 12, 13, 14, 15),
) as now_mock:
timestamp, datestamp = get_v4_now_dtstamps()
now_mock.assert_called_once()

self.assertEqual(timestamp, "20200312T131415Z")
self.assertEqual(datestamp, "20200312")


_DUMMY_SERVICE_ACCOUNT = None


Expand Down
Loading